From c19262a0856af0d4ed18244737c24d87dff337ab Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:38:17 +0200 Subject: [PATCH] feat: make the deployment footprint panel calm and readable --- .../docs/deployment/container-hardening.md | 15 ++- e2e/deployment-footprint.spec.ts | 92 +++++++++++++++ src/pages/Dashboard.test.tsx | 77 +++++++++++-- src/pages/Dashboard.tsx | 109 ++++++++++++++++-- src/pages/Dashboard.units.test.tsx | 2 + src/styles.css | 76 ++++++++++-- 6 files changed, 338 insertions(+), 33 deletions(-) create mode 100644 e2e/deployment-footprint.spec.ts diff --git a/docs/src/content/docs/deployment/container-hardening.md b/docs/src/content/docs/deployment/container-hardening.md index 0854ca02..d2ec7dbd 100644 --- a/docs/src/content/docs/deployment/container-hardening.md +++ b/docs/src/content/docs/deployment/container-hardening.md @@ -140,8 +140,19 @@ docker compose exec edgewatch edgewatch health --config /etc/edgewatch/config.ya `scanner_sandbox.state` is `enforced`, `disabled` or `unavailable`, and `reason` explains a sandbox that is not enforced. `scanner_sandbox.landlock` -reports [Landlock](#landlock) the same way. The Overview's deployment -footprint shows both. +reports [Landlock](#landlock) the same way. + +Administrators see the same information on the Overview, in the deployment +footprint's **Process isolation** section. Each sandbox has a status: + +- **Enforced:** every layer holds. +- **Partial:** some layers hold. +- **Unavailable:** no layer holds. +- **Off:** the setting turns the sandbox off. + +The section marks each layer (the sandbox identity, Landlock, and the +[seccomp filter](#seccomp-filter)) as holding, unavailable, or off, and gives +the reason for each layer that does not hold. The sandbox needs the user namespace of the container to map UID 65532. Standard rootful Docker, rootless Docker, and user-namespace remapping with a full diff --git a/e2e/deployment-footprint.spec.ts b/e2e/deployment-footprint.spec.ts new file mode 100644 index 00000000..0963c3ce --- /dev/null +++ b/e2e/deployment-footprint.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Page } from '@playwright/test' +import { mockConsole } from './mock-console' + +// The footprint lays its counters out with container queries, which jsdom +// cannot check: render it with deployment-sized counts at phone, tablet and +// desktop widths and require every counter, label and status to stay inside +// its own cell. +const widths = [320, 390, 1000, 1280, 1440] + +const largeTelemetry = { collected_at: '2026-10-08T18:03:00Z', database_bytes: 1.2 * 1024 ** 4, jobs: 120, scans: 987_654, host_observations: 12_345_678, effective_hosts: 1_234_567, events: 99_999_999, scan_cycles: 4000, outbox_pending: 12_345, outbox_retrying: 0, outbox_failed: 0 } + +const degradedSandboxes = { + scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'the container does not grant SETUID, SETGID, KILL, which EdgeWatch needs to start and stop scanner processes as UID 65532; add them to cap_add', landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'unavailable', reason: 'nmap could not start with the seccomp filter: signal: bad system call' } }, + notification_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'a test notification process could not start as UID 65531: read SSL_CERT_FILE: permission denied', landlock: { mode: 'auto', state: 'unavailable', reason: 'the kernel does not provide Landlock' }, seccomp: { state: 'unavailable', reason: 'the seccomp filter applies only with Landlock' } }, +} + +const healthySandboxes = { + scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW', 'NET_ADMIN'], no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'enforced' } }, + notification_sandbox: { mode: 'auto', state: 'enforced', uid: 65531, gid: 65531, process_uid: 65531, no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'enforced' } }, +} + +async function showFootprint(page: Page, status: Record) { + await mockConsole(page, 'administrator') + await page.route('**/api/v1/status', route => route.fulfill({ json: { configured: true, username: 'administrator', display_name: 'administrator', role: 'administrator', version: 'v0.30.1', notification_destinations: 1, notifications: { deployment: 0, managed: 1, active: 1, locked: 0, key_state: 'ready' }, retention: '2160h0m0s', max_concurrent_scans: 2, ...status } })) + await page.goto('/') + await expect(page.locator('.deployment-telemetry')).toBeVisible() +} + +// Lists every footprint element whose text or box leaves the cell, strip, or +// element it belongs to, with enough detail to see which one at which width. +async function footprintOverflow(page: Page) { + return page.locator('.deployment-telemetry').evaluate(panel => { + const problems: string[] = [] + const strip = panel.querySelector('.telemetry-strip')!.getBoundingClientRect() + for (const cell of panel.querySelectorAll('.telemetry-metric')) { + const bounds = cell.getBoundingClientRect() + for (const part of cell.querySelectorAll('dt, dd')) { + const range = document.createRange() + range.selectNodeContents(part) + const text = range.getBoundingClientRect() + if (text.right > bounds.right - 1 || text.left < bounds.left) problems.push(`${part.tagName} "${part.textContent}" leaves its cell (${Math.round(text.left)}-${Math.round(text.right)} in ${Math.round(bounds.left)}-${Math.round(bounds.right)})`) + } + const label = cell.querySelector('dt')! + if (label.scrollWidth > label.clientWidth) problems.push(`label "${label.textContent}" is truncated`) + } + for (const element of panel.querySelectorAll('.isolation-name, .isolation-state, .isolation-layer, .isolation-capabilities, .isolation-reasons li')) { + const box = element.getBoundingClientRect() + if (box.right > strip.right + 0.5 || box.left < strip.left - 0.5) problems.push(`"${element.textContent}" leaves the strip`) + if (element.scrollWidth > element.clientWidth + 1) problems.push(`"${element.textContent}" overflows itself`) + } + if (document.documentElement.scrollWidth > document.documentElement.clientWidth) problems.push('the page scrolls horizontally') + return problems + }) +} + +async function metricRows(page: Page) { + const tops = await page.locator('.telemetry-metric').evaluateAll(cells => cells.map(cell => Math.round(cell.getBoundingClientRect().top))) + return new Set(tops).size +} + +test('the deployment footprint keeps large counters and sandbox details inside their cells', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'One browser renders every width.') + await showFootprint(page, { telemetry: largeTelemetry, ...degradedSandboxes }) + await expect(page.locator('.telemetry-metric dd')).toHaveText(['1.2 TB', '1,234,567', '12,345,678', '987,654', '99,999,999', '12,345']) + for (const width of widths) { + await page.setViewportSize({ width, height: 1200 }) + expect(await footprintOverflow(page), `at ${width}px`).toEqual([]) + } + // Six counters share one row on a desktop panel and two columns on a phone. + await page.setViewportSize({ width: 1280, height: 1200 }) + expect(await metricRows(page)).toBe(1) + await page.setViewportSize({ width: 390, height: 1200 }) + expect(await metricRows(page)).toBe(3) +}) + +test('the deployment footprint of a unit leaves no empty cell without the database size', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'One browser renders every width.') + const { database_bytes: _database, ...unitTelemetry } = largeTelemetry + await showFootprint(page, { telemetry: unitTelemetry, ...healthySandboxes }) + await expect(page.locator('.telemetry-metric dt')).toHaveText(['Effective hosts', 'Host observations', 'Retained scans', 'Events', 'Pending delivery']) + for (const width of widths) { + await page.setViewportSize({ width, height: 1200 }) + expect(await footprintOverflow(page), `at ${width}px`).toEqual([]) + // The last counter spans what is left of its row, so no cell is empty. + const lastRow = await page.locator('.telemetry-metrics').evaluate(list => { + const cells = Array.from(list.children).map(cell => cell.getBoundingClientRect()) + const last = cells[cells.length - 1] + return { right: Math.round(last.right), listRight: Math.round(list.getBoundingClientRect().right) } + }) + expect(Math.abs(lastRow.right - lastRow.listRight), `last counter ends at the strip edge at ${width}px`).toBeLessThanOrEqual(1) + } +}) diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx index 44cf8019..08509246 100644 --- a/src/pages/Dashboard.test.tsx +++ b/src/pages/Dashboard.test.tsx @@ -110,6 +110,25 @@ function LocationProbe() { return {location.pathname} } +// Reads one sandbox of the footprint's process isolation list as an +// administrator or a screen reader reads it: its status, each layer with the +// state that follows its name, and one line per reason a layer does not hold. +function isolationEntry(container: HTMLElement, name: string) { + const heading = Array.from(container.querySelectorAll('.deployment-telemetry h3')).find(element => element.textContent === 'Process isolation') + const list = Array.from(container.querySelectorAll('.deployment-telemetry ul')).find(element => heading?.id && element.getAttribute('aria-labelledby') === heading.id) + const entry = Array.from(list?.children ?? []).find(item => item.querySelector('.isolation-name')?.textContent === name) + if (!entry) return undefined + const state = entry.querySelector('.isolation-state') + return { + state: state?.textContent, + tone: Array.from(state?.classList ?? []).find(token => ['green', 'amber', 'red', 'gray'].includes(token)), + icon: state?.querySelector('svg') != null, + layers: Array.from(entry.querySelectorAll(`ul[aria-label="${name} layers"] > li`)).map(item => item.textContent), + reasons: Array.from(entry.querySelectorAll('.isolation-reasons > li')).map(item => item.textContent), + keeps: entry.querySelector('.isolation-capabilities')?.textContent ?? '', + } +} + describe('dashboard', () => { let root: Root let container: HTMLDivElement @@ -158,7 +177,13 @@ describe('dashboard', () => { await renderDashboard() expect(container.textContent).toContain('Good day, Alice') expect(container.textContent).toContain('Deployment footprint') - expect(container.textContent).toContain('1.5 KB') + const footprint = container.querySelector('.deployment-telemetry') + const metrics = Array.from(footprint?.querySelectorAll('dl > div') ?? []).map(metric => [metric.querySelector('dt')?.textContent, metric.querySelector('dd')?.textContent]) + expect(metrics).toEqual([['Database', '1.5 KB'], ['Effective hosts', '1'], ['Host observations', '1'], ['Retained scans', '1'], ['Events', '1'], ['Pending delivery', '0']]) + const collected = footprint?.querySelector('.panel-heading time') + expect(footprint?.querySelector('.telemetry-updated')?.textContent).toBe(`Collected ${collected?.textContent}`) + expect(collected?.textContent).toMatch(/^\d{1,2}:\d{2}/) + expect(collected?.getAttribute('datetime')).toBe('2026-09-12T08:00:00Z') expect(container.textContent).toContain('Naabu → Nmap · tcp discovery · TCP') expect(container.textContent).toContain('batch 1/2') expect(container.textContent).toContain('2 ports found across 1 hosts') @@ -443,20 +468,22 @@ describe('dashboard', () => { it('shows the enforced scanner sandbox in the deployment footprint', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW'], no_new_privileges: true } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxEnforced · NET_RAW'), { timeout: 1000 }) + await vi.waitFor(() => expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Enforced', tone: 'green', icon: true, layers: ['UID 65532: enforced'], reasons: [], keeps: 'Keeps NET_RAW' }), { timeout: 1000 }) + expect(isolationEntry(container, 'Notifications')).toBeUndefined() expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() }) it('shows the notification sandbox in the deployment footprint', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', process_uid: 65532, capabilities: ['NET_RAW'] }, notification_sandbox: { mode: 'auto', state: 'enforced', uid: 65531, gid: 65531, process_uid: 65531, no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 } } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Notification sandboxEnforced · Landlock'), { timeout: 1000 }) + await vi.waitFor(() => expect(isolationEntry(container, 'Notifications')).toEqual({ state: 'Enforced', tone: 'green', icon: true, layers: ['UID 65531: enforced', 'Landlock: enforced'], reasons: [], keeps: '' }), { timeout: 1000 }) + expect(isolationEntry(container, 'Scanners')).toMatchObject({ layers: ['UID 65532: enforced'], keeps: 'Keeps NET_RAW' }) }) it('shows Landlock beside the enforced scanner sandbox', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW', 'NET_ADMIN'], no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'enforced' } } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxEnforced · NET_RAW, NET_ADMIN · Landlock · seccomp'), { timeout: 1000 }) + await vi.waitFor(() => expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Enforced', tone: 'green', icon: true, layers: ['UID 65532: enforced', 'Landlock: enforced', 'seccomp: enforced'], reasons: [], keeps: 'Keeps NET_RAW, NET_ADMIN' }), { timeout: 1000 }) expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() }) @@ -464,30 +491,64 @@ describe('dashboard', () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'the container does not grant KILL', landlock: { mode: 'auto', state: 'enforced', abi: 6 } } }) await renderDashboard() await vi.waitFor(() => expect(container.querySelector('.scanner-sandbox-warning')?.textContent).toContain('Scanner processes run as UID 0, restricted only by Landlock. the container does not grant KILL'), { timeout: 1000 }) - expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxLandlock only') + expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Partial', tone: 'amber', icon: true, layers: ['Identity: unavailable', 'Landlock: enforced'], reasons: ['Identity: The container does not grant KILL.'], keeps: '' }) }) it('warns an administrator when scanner processes run unconfined as UID 0', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'the container does not grant KILL, which EdgeWatch needs to start and stop scanner processes as UID 65532; add them to cap_add', landlock: { mode: 'auto', state: 'unavailable', reason: 'the kernel does not provide Landlock' } } }) await renderDashboard() await vi.waitFor(() => expect(container.querySelector('.scanner-sandbox-warning')?.textContent).toContain('Scanner processes run unconfined as UID 0. the container does not grant KILL'), { timeout: 1000 }) - expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxUnavailable') + expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Unavailable', tone: 'red', icon: true, layers: ['Identity: unavailable', 'Landlock: unavailable'], reasons: ['Identity: The container does not grant KILL, which EdgeWatch needs to start and stop scanner processes as UID 65532; add them to cap_add.', 'Landlock: The kernel does not provide Landlock.'], keeps: '' }) }) it('does not warn when scanner processes already run without root', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 1000, reason: 'EdgeWatch runs as UID 1000 rather than 0' } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxUnavailable'), { timeout: 1000 }) + await vi.waitFor(() => expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Unavailable', tone: 'red', icon: true, layers: ['Identity: unavailable'], reasons: ['Identity: EdgeWatch runs as UID 1000 rather than 0.'], keeps: '' }), { timeout: 1000 }) expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() }) it('labels a scanner sandbox that is off', async () => { vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'off', state: 'disabled', process_uid: 0, reason: 'scanner.sandbox is off' } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxOff'), { timeout: 1000 }) + await vi.waitFor(() => expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Off', tone: 'gray', icon: true, layers: ['Identity: off'], reasons: ['Identity: scanner.sandbox is off.'], keeps: '' }), { timeout: 1000 }) + expect(container.querySelector('.isolation-reasons code')?.textContent).toBe('scanner.sandbox') expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() }) + it('shows the reason a notification sandbox is unavailable', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, notification_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'a test notification process could not start as UID 65531: read SSL_CERT_FILE: permission denied', landlock: { mode: 'auto', state: 'unavailable', reason: 'the kernel does not provide Landlock' }, seccomp: { state: 'unavailable', reason: 'the seccomp filter applies only with Landlock' } } }) + await renderDashboard() + await vi.waitFor(() => expect(isolationEntry(container, 'Notifications')).toEqual({ + state: 'Unavailable', tone: 'red', icon: true, + layers: ['Identity: unavailable', 'Landlock: unavailable', 'seccomp: unavailable'], + // The seccomp filter is installed with Landlock, so it shares the Landlock reason. + reasons: ['Identity: A test notification process could not start as UID 65531: read SSL_CERT_FILE: permission denied.', 'Landlock, seccomp: The kernel does not provide Landlock.'], + keeps: '', + }), { timeout: 1000 }) + expect(isolationEntry(container, 'Scanners')).toBeUndefined() + }) + + it('never shows a sandbox as enforced while one of its layers does not hold', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW'], landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'unavailable', reason: 'nmap could not start with the seccomp filter: signal: bad system call' } } }) + await renderDashboard() + await vi.waitFor(() => expect(isolationEntry(container, 'Scanners')).toEqual({ state: 'Partial', tone: 'amber', icon: true, layers: ['UID 65532: enforced', 'Landlock: enforced', 'seccomp: unavailable'], reasons: ['seccomp: Nmap could not start with the seccomp filter: signal: bad system call.'], keeps: 'Keeps NET_RAW' }), { timeout: 1000 }) + expect(container.querySelector('.deployment-telemetry .panel-heading p')?.textContent).toBe('Storage, scale, and process isolation of this deployment.') + }) + + it('names every layer one reason explains when the sandbox is off', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, notification_sandbox: { mode: 'off', state: 'disabled', process_uid: 0, reason: 'notifications.sandbox is off', landlock: { mode: 'off', state: 'disabled', reason: 'notifications.sandbox is off' }, seccomp: { state: 'disabled', reason: 'the seccomp filter applies only with Landlock' } } }) + await renderDashboard() + await vi.waitFor(() => expect(isolationEntry(container, 'Notifications')).toEqual({ state: 'Off', tone: 'gray', icon: true, layers: ['Identity: off', 'Landlock: off', 'seccomp: off'], reasons: ['Identity, Landlock, seccomp: notifications.sandbox is off.'], keeps: '' }), { timeout: 1000 }) + }) + + it('leaves process isolation out for a daemon that reports no sandbox', async () => { + await renderDashboard() + await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')).not.toBeNull(), { timeout: 1000 }) + expect(container.querySelector('.deployment-telemetry')?.textContent).not.toContain('Process isolation') + expect(container.querySelector('.deployment-telemetry .panel-heading p')?.textContent).toBe('Cached storage and operational scale indicators.') + }) + it('does not show the scanner sandbox warning to an operator', async () => { vi.mocked(getSession).mockResolvedValue({ ...session('operator'), permissions: ['overview.read', 'jobs.read', 'jobs.write', 'scans.read', 'incidents.read'] }) vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'off', state: 'disabled', process_uid: 0 } }) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 45458d2b..b05ef415 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' -import { Activity, AlertTriangle, Bell, CheckCircle2, Clock3, Database, Play, Radar, ShieldAlert } from 'lucide-react' -import { activeScans, adminStatus, cancelQueuedRun, cancelScan, getSession, listIncidents, listJobs, listScans, notificationTest, runJob } from '../api' +import { useId, useState } from 'react' +import { Activity, AlertTriangle, Bell, Check, CheckCircle2, Clock3, Database, Minus, Play, Radar, ShieldAlert, ShieldCheck, ShieldHalf, ShieldOff, ShieldX, X } from 'lucide-react' +import { activeScans, adminStatus, cancelQueuedRun, cancelScan, getSession, listIncidents, listJobs, listScans, notificationTest, runJob, type DeploymentTelemetry, type ScannerSandboxStatus } from '../api' import { Link, useNavigate } from 'react-router-dom' import { formatDate, formatDateTime, formatRetention, formatTime } from '../format' import { baselinePresentation, type BaselineStatusInfo } from '../baseline' @@ -88,7 +88,7 @@ export function Dashboard() {

Monitoring console

Good day, {displayName}

A calm view of your network’s expected surface. {!canOperate ? '' : notificationCount ? `${notificationCount} notification destination${notificationCount === 1 ? '' : 's'} configured.` : !canManageNotifications ? 'Notifications are configured by an administrator.' : ''} {policy}

{isAdmin && }{canOperate && }
{notifyState &&
{notifyState.state === 'success' ? : }{notifyState.text}
}{canManageNotifications && notificationCount === 0 &&
No active notification destinations — alerts are not being delivered. Add a destination.
}{runError &&
{runError}
}{setup.error && setup.refetch()} />}{canOperate && active.error && active.refetch()} />}{canOperate && incidents.error && incidents.refetch()} />}{canOperate && setup.data?.legacy_yaml_jobs?.length ?
Legacy YAML jobs are inactive. Recreate {setup.data.legacy_yaml_jobs.join(', ')} in the console to resume scheduling.
: null}{scannerUnconfinedAsRoot &&
{scannerSandbox?.landlock?.state === 'enforced' ? 'Scanner processes run as UID 0, restricted only by Landlock.' : 'Scanner processes run unconfined as UID 0.'} {scannerSandbox?.reason ? `${scannerSandbox.reason}.` : ''} See “Container runtime hardening” in the EdgeWatch documentation.
}
} label="Active jobs" value={activeJobs} detail={`${ready} baselines ready`} tone="blue" status={jobsMetricState} onRetry={() => jobs.refetch()} />} label="Healthy baselines" value={ready} detail="Stable monitoring scopes" tone="green" status={jobsMetricState} onRetry={() => jobs.refetch()} />{canOperate && } label="Open incidents" value={incidentTotal} detail="Confirmed changes" tone="amber" status={incidentsMetricState} onRetry={() => incidents.refetch()} />}} label="Scan history" value={scanTotal} detail="Retained scan records" tone="purple" status={scansMetricState} onRetry={() => scans.refetch()} />
- {isAdmin && telemetry &&

Deployment footprint

Cached storage and operational scale indicators.

{telemetry.database_bytes !== undefined && }{scannerSandbox && }{notificationSandbox && }
Collected {formatTime(telemetry.collected_at, { hour: '2-digit', minute: '2-digit' })}
} + {isAdmin && telemetry && } {canOperate && ((active.data?.scans.length ?? 0) > 0 || queuedRuns.length > 0) ?

{queuedRuns.length ? 'Scans in progress or queued' : 'Scans in progress'}

Broad scans can take time; progress follows Nmap task updates when available and reports process liveness between them.

{queuedRuns.map(run => )}{(active.data?.scans ?? []).map(scan => )}
: null}

Jobs at a glance

{canOperate ? 'Run or inspect any saved job.' : 'Inspect saved jobs and their baselines.'}

{jobs.isLoading ?
: jobs.error ? jobs.refetch()} /> : jobs.data?.jobs.length ?
{jobs.data.jobs.slice(0, 5).map(job =>
{job.job.name}{job.job.targets.length} targets · {job.job.schedule}
{canOperate && }
)}
:
No jobs configured yet.{canOperate && }
}

Latest activity

The most recent scan outcomes.

{scans.isLoading ?
: scans.error ? scans.refetch()} /> : scans.data?.scans.length ?
{scans.data.scans.slice(0, 6).map(scan => )}
:
Your first scan will appear here.
}
@@ -161,16 +161,101 @@ function BaselinePill({ baseline }: { baseline: BaselineStatusInfo }) { return {presentation.label} } -function sandboxLabel(status: NonNullable>['scanner_sandbox']>) { - const landlock = status.landlock?.state === 'enforced' - const restrictions = [...(landlock ? ['Landlock'] : []), ...(status.seccomp?.state === 'enforced' ? ['seccomp'] : [])] - if (status.state === 'enforced') return [status.capabilities?.length ? `Enforced · ${status.capabilities.join(', ')}` : 'Enforced', ...restrictions].join(' · ') - if (landlock) return 'Landlock only' - if (status.state === 'disabled') return 'Off' - return 'Unavailable' +// The footprint shows the cached counters in one strip, then how the scanner +// and notification processes are isolated. Either sandbox may be absent on an +// older daemon, and a unit view leaves out the database size. +function DeploymentFootprint({ telemetry, scannerSandbox, notificationSandbox }: { telemetry: DeploymentTelemetry; scannerSandbox?: ScannerSandboxStatus; notificationSandbox?: ScannerSandboxStatus }) { + const isolationID = useId() + const metrics = [ + ...(telemetry.database_bytes === undefined ? [] : [{ label: 'Database', value: formatBytes(telemetry.database_bytes) }]), + { label: 'Effective hosts', value: telemetry.effective_hosts.toLocaleString() }, + { label: 'Host observations', value: telemetry.host_observations.toLocaleString() }, + { label: 'Retained scans', value: telemetry.scans.toLocaleString() }, + { label: 'Events', value: telemetry.events.toLocaleString() }, + { label: 'Pending delivery', value: telemetry.outbox_pending.toLocaleString() }, + ] + const sandboxes = ([['Scanners', scannerSandbox], ['Notifications', notificationSandbox]] as const).flatMap(([name, status]) => status ? [{ name, status }] : []) + return
+

Deployment footprint

{sandboxes.length ? 'Storage, scale, and process isolation of this deployment.' : 'Cached storage and operational scale indicators.'}

Collected
+
+
{metrics.map(({ label, value }) =>
{label}
{value}
)}
+ {sandboxes.length > 0 &&

Process isolation

    {sandboxes.map(({ name, status }) => )}
} +
+
+} + +type SandboxLayer = { name: string; label: string; state: string; reason?: string } + +const layerStateText: Record = { enforced: 'enforced', disabled: 'off', unavailable: 'unavailable' } + +function SandboxEntry({ name, status }: { name: string; status: ScannerSandboxStatus }) { + const { label, tone, Icon } = sandboxPresentation(status) + const layers = sandboxLayers(status) + const reasons = layerReasons(layers) + return
  • + {name} + +
    +
      {layers.map(layer => { + const LayerIcon = layer.state === 'enforced' ? Check : layer.state === 'disabled' ? Minus : X + return
    • + })}
    {status.state === 'enforced' && status.capabilities?.length ? Keeps {status.capabilities.join(', ')} : null}
    + {reasons.length > 0 &&
      {reasons.map(({ names, reason }) =>
    • {names.join(', ')}:
    • )}
    } +
    +
  • +} + +// A sandbox confines its processes in up to three layers: the sandbox +// identity, Landlock, and the seccomp filter. The capabilities the confined +// identity keeps are shown after the layers, since they grant rather than +// confine. Older daemons report no Landlock or seccomp state; those layers are +// left out rather than shown as missing. +function sandboxLayers(status: ScannerSandboxStatus): SandboxLayer[] { + return [ + { name: 'Identity', label: status.state === 'enforced' ? `UID ${status.uid ?? status.process_uid}` : 'Identity', state: status.state, reason: status.reason }, + ...(status.landlock ? [{ name: 'Landlock', label: 'Landlock', state: status.landlock.state, reason: status.landlock.reason }] : []), + ...(status.seccomp ? [{ name: 'seccomp', label: 'seccomp', state: status.seccomp.state, reason: status.seccomp.reason }] : []), + ] } -function TelemetryMetric({ label, value }: { label: string; value: string }) { return
    {label}{value}
    } +// Enforced only when every reported layer holds, so a sandbox whose seccomp +// filter failed never shows green. Off is the configured opt-out; Partial +// still confines the processes in some layer, and Unavailable in none. +function sandboxPresentation(status: ScannerSandboxStatus) { + const layers = sandboxLayers(status) + const holding = layers.filter(layer => layer.state === 'enforced').length + if (holding === layers.length) return { label: 'Enforced', tone: 'green', Icon: ShieldCheck } + if (status.state === 'disabled' && holding === 0) return { label: 'Off', tone: 'gray', Icon: ShieldOff } + if (holding > 0) return { label: 'Partial', tone: 'amber', Icon: ShieldHalf } + return { label: 'Unavailable', tone: 'red', Icon: ShieldX } +} + +// One line per reason, naming the layers it explains. The seccomp filter is +// installed with Landlock, so while Landlock does not hold, seccomp shares its +// reason; layers whose reasons are the same share one line. +function layerReasons(layers: SandboxLayer[]) { + const landlockHolds = layers.find(layer => layer.name === 'Landlock')?.state === 'enforced' + const lines: { names: string[]; reason: string }[] = [] + for (const layer of layers) { + if (layer.state === 'enforced') continue + const reason = (layer.name === 'seccomp' && !landlockHolds ? layers.find(candidate => candidate.name === 'Landlock')?.reason : layer.reason)?.trim() + if (!reason) continue + const line = lines.find(candidate => candidate.reason === reason) + if (line) line.names.push(layer.name) + else lines.push({ names: [layer.name], reason }) + } + return lines +} + +// Reasons arrive as lowercase clauses. Start a sentence with them, except +// when the clause opens with a configuration key such as scanner.sandbox, +// which keeps its case and is set as code. +function SandboxReason({ text }: { text: string }) { + const [first = '', ...rest] = text.replace(/[\s.]+$/, '').split(' ') + const tail = `${rest.length ? ` ${rest.join(' ')}` : ''}.` + if (/^[a-z_]+(\.[a-z_]+)+$/.test(first)) return <>{first}{tail} + return <>{first.charAt(0).toUpperCase() + first.slice(1) + tail} +} function formatBytes(value: number) { if (!Number.isFinite(value) || value < 1024) return `${Math.max(0, Math.round(value || 0))} B` diff --git a/src/pages/Dashboard.units.test.tsx b/src/pages/Dashboard.units.test.tsx index 6601a8db..eaff7fbe 100644 --- a/src/pages/Dashboard.units.test.tsx +++ b/src/pages/Dashboard.units.test.tsx @@ -30,6 +30,8 @@ describe('dashboard footprint with business units', () => { renderWithProviders() const footprint = (await screen.findByRole('heading', { name: 'Deployment footprint' })).closest('.panel') as HTMLElement expect(within(footprint).getByText('Effective hosts')).toBeInTheDocument() + expect(within(footprint).getAllByRole('term').map(term => term.textContent)).toEqual(['Effective hosts', 'Host observations', 'Retained scans', 'Events', 'Pending delivery']) + expect(within(footprint).getAllByRole('definition').map(value => value.textContent)).toEqual(['4', '3', '2', '5', '7']) expect(within(footprint).queryByText('Database')).not.toBeInTheDocument() expect(within(footprint).queryByText('0 B')).not.toBeInTheDocument() }) diff --git a/src/styles.css b/src/styles.css index 1fba3619..38439bb2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -956,15 +956,71 @@ .public-updated { display: block; margin-top: 10px; } } -.deployment-telemetry { margin-bottom: 18px; } -.telemetry-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 9px; } -.telemetry-metric { min-width: 0; padding: 10px; border: 1px solid #1d3854; border-radius: 7px; background: #0a1929; } -.telemetry-metric span, .telemetry-metric strong { display: block; } -.telemetry-metric span { overflow: hidden; color: #7895b2; font-size: 9px; letter-spacing: .07em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } -.telemetry-metric strong { margin-top: 5px; color: #dcecff; font-size: 16px; } -.telemetry-updated { display: block; margin-top: 10px; font-size: 10px; } -@media (max-width: 1050px) { .telemetry-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@media (max-width: 760px) { .telemetry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* Deployment footprint: one strip of counters with hairline dividers, then + the process isolation of each sandbox. The strip sizes itself to its own + width, because the sidebar makes the viewport a poor guide; on a wide strip + the isolation entries line up with the second counter. */ +.deployment-telemetry { margin-bottom: 18px; container: footprint-panel / inline-size; } +.telemetry-heading-meta { display: flex; flex: 0 0 auto; align-items: center; gap: 10px; } +.telemetry-updated { color: var(--muted); font-size: 12px; line-height: 18px; white-space: nowrap; } +.telemetry-updated time { color: #c3d2e3; font-variant-numeric: tabular-nums; } +.telemetry-strip { container: footprint / inline-size; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: rgba(6, 15, 28, .45); } +.telemetry-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: -1px 0 0 -1px; } +.telemetry-metric { display: flex; flex-direction: column; justify-content: space-between; gap: 6px; min-width: 0; padding: 13px 14px 12px; border-top: 1px solid var(--line); border-left: 1px solid var(--line); } +.telemetry-strip[data-columns="5"] .telemetry-metric:last-child { grid-column: 1 / -1; } +.telemetry-metric dt { color: var(--muted); font-size: 12px; line-height: 1.35; } +/* Counters scale with their column, so an eight-digit count still fits its + cell; a longer one wraps inside the cell rather than running into the + next. */ +.telemetry-metric dd { margin: 0; color: #eef4fb; font-size: clamp(16px, 6.8cqi, 21px); font-weight: 650; letter-spacing: -.02em; line-height: 1.15; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.telemetry-isolation { padding: 12px 14px 14px; border-top: 1px solid var(--line); } +.telemetry-isolation h3 { margin: 0 0 9px; color: var(--muted); font-size: 12px; font-weight: 600; line-height: 1.35; letter-spacing: 0; } +.isolation-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; margin: 0; padding: 0; list-style: none; } +.isolation-entry { display: grid; grid-column: 1 / -1; column-gap: 12px; grid-template-columns: 7.5em minmax(0, 1fr); grid-template-columns: subgrid; align-items: start; min-width: 0; } +.isolation-name { color: #dcecff; font-size: 13px; font-weight: 600; line-height: 22px; white-space: nowrap; } +.isolation-state.pill { justify-self: start; gap: 5px; min-height: 22px; margin: 0; padding: 3px 9px; line-height: 16px; } +.isolation-state svg { flex: 0 0 auto; } +.isolation-details { display: grid; grid-column: 1 / -1; gap: 5px; min-width: 0; margin-top: 7px; } +.isolation-layer-row { display: flex; flex-wrap: wrap; align-items: center; gap: 5px 10px; min-width: 0; } +.isolation-layers { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; } +.isolation-capabilities { color: var(--muted); font-size: 12px; line-height: 22px; white-space: nowrap; } +.isolation-layer { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 6px; border-radius: 6px; background: #10233a; color: #c3d2e3; font-size: 12px; line-height: 18px; white-space: nowrap; } +.isolation-layer.holds svg { color: var(--green); } +.isolation-layer.fails { background: rgba(243, 125, 131, .1); color: #f4b6ba; } +.isolation-layer.fails svg { color: var(--red); } +.isolation-layer.off { background: #172536; color: #a5b3c5; } +.isolation-layer.off svg { color: #8ca0b9; } +.isolation-reasons { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.isolation-reasons li { color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.isolation-reason-layers { color: #c3d2e3; font-weight: 600; } +.isolation-reasons code { color: #c3d2e3; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; } +@container footprint (min-width: 432px) { + .telemetry-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .telemetry-metric { padding: 14px 16px 13px; } + .telemetry-metric dd { font-size: clamp(16px, 4.6cqi, 21px); } + .telemetry-strip[data-columns="5"] .telemetry-metric:last-child { grid-column: span 2; } + .telemetry-isolation { padding: 12px 16px 14px; } +} +@container footprint (min-width: 560px) { + /* The status column keeps the width of its widest word, Unavailable, so + the layers start at the same place whatever the sandboxes report. */ + .isolation-list { grid-template-columns: max-content minmax(7rem, max-content) minmax(0, 1fr); gap: 10px 14px; } + .isolation-entry { column-gap: 14px; grid-template-columns: 7.5em 7rem minmax(0, 1fr); grid-template-columns: subgrid; } + .isolation-details { grid-column: auto; margin-top: 0; } +} +@container footprint (min-width: 870px) { + .telemetry-metrics, .telemetry-isolation { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .telemetry-strip[data-columns="5"] :is(.telemetry-metrics, .telemetry-isolation) { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .telemetry-strip[data-columns="5"] .telemetry-metric:last-child { grid-column: auto; } + .telemetry-metric dd { font-size: clamp(16px, 2.3cqi, 21px); } + .telemetry-isolation { display: grid; align-items: start; padding: 12px 0 13px; } + .telemetry-isolation h3 { grid-column: 1; margin: 0; padding: 0 16px; line-height: 22px; white-space: nowrap; } + .isolation-list { grid-column: 2 / -1; padding: 0 16px; } +} +@container footprint-panel (max-width: 479px) { + .deployment-telemetry .panel-heading { flex-direction: column; gap: 6px; } + .telemetry-heading-meta .muted-icon { display: none; } +} /* Keep keyboard focus visible on rows that navigate to details. The dark inset ring remains legible against both the row hover state and the page @@ -2042,8 +2098,6 @@ th, .job-notification-option .pill, .evidence-port-row, .scanner-run-summary strong, -.telemetry-metric span, -.telemetry-updated, .stat-retry, .surface-unit-addresses, .surface-port,