feat: security center page (/security)

Project-scoped security surface: scan trigger (dependency /
configuration / comprehensive, optional service), security score and
vulnerability metric cards, findings list with severity badges and
resolve/ignore actions, and scan history.

Fixed a latent bug in GET /projects/:id/security/metrics: it selected a
non-existent `score` column (the score lives in the security_scans
summary jsonb), so the endpoint 500'd on every project.

Verified live: comprehensive scan produced 4 real findings (insecure
public URL, curl|sh build, http source, unpinned tag) with score 47;
resolve flow and metrics aggregation confirmed.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 18:24:19 +02:00
1 parent d5c99acb8c
commit d5e6bfc40d
6 files changed
+345 -9

No files matched your search

+11 -5
View File
@@ -49,7 +49,7 @@ The backend exposes **~95 route registrations**; the frontend consumes
| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | not documented |
| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | mentioned in README |
| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | not documented |
| Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it |
| Security scans, vulnerabilities, compliance/GDPR reports | done (heuristic scanner — image tags, insecure URLs, build-command smells; no Trivy/Grype binary scanning) | done — `/security` page (scan trigger, findings, metrics, history) | README lists it |
| Audit logs | done | done — `/settings/audit-logs` filterable page | done |
| Autoscaling policies + manual scale | done | done (beta) | per-service Scaling section on service detail; policies in-memory, lost on restart |
| HA / failover policies | done | done (beta) | `/ha` page: manager toggle, failover policies, alerts, health results; state is in-memory |
@@ -372,10 +372,16 @@ reaches the upstream container and appears in the analytics tables.
picker), active alerts with resolve, health check results. Verified
live. Beta: policies/alerts are in-memory, lost on restart.
Health-check and notifier CRUD endpoints remain unexposed.
- [ ] **Security center** (`/security/*`): scan trigger per project, finding
list with severity, compliance report viewer, audit correlation.
Verify the scanner actually runs Trivy/Grype or document the real
mechanism.
- [x] **Security center** (`/security/*`): `/security` page with project
picker, scan trigger (type + optional service), security score and
vuln metrics cards, findings list with resolve/ignore, scan history.
Fixed latent bug: `security/metrics` queried a non-existent `score`
column (score lives in `summary` jsonb) — was 500 on every project.
Verified live: comprehensive scan produced 4 findings (3 high, 1
medium), resolve flow, metrics aggregation. Real mechanism
documented: heuristic config analysis, NOT Trivy/Grype.
Remaining: compliance assessment/report viewer UI, security audit
correlation view.
- [ ] **Multi-node scheduling**: service → node placement rules using agent
telemetry; health-based rescheduling. Depends on Phase 2 agents.
- [ ] **Infrastructure providers over Terraform** (per §2.2):
+4 -4
View File
@@ -394,10 +394,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
}
err = sh.db.QueryRow(`
SELECT id, score, started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
+2
View File
@@ -14,6 +14,7 @@ import {
UsagePage,
DatabasesPage,
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
@@ -65,6 +66,7 @@ export default function App() {
<Route path="/usage" element={<UsagePage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
@@ -15,6 +15,7 @@ import {
RefreshCw,
Search,
Settings,
Shield,
ShieldCheck,
Sun,
UploadCloud,
@@ -36,6 +37,7 @@ const navItems = [
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Databases', href: '/databases', icon: Database },
{ label: 'HA', href: '/ha', icon: ShieldCheck },
{ label: 'Security', href: '/security', icon: Shield },
{ label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users },
{ label: 'Settings', href: '/settings', icon: Settings },
@@ -38,6 +38,11 @@ import {
listActiveAlerts,
resolveAlert,
listHealthResults,
startSecurityScan,
getSecurityHistory,
listVulnerabilities,
updateVulnerability,
getSecurityMetrics,
type AgentAuthTokenCreated,
type DatabaseEntity,
type FailoverPolicy,
@@ -2303,3 +2308,268 @@ export function HighAvailabilityPage() {
</div>
);
}
const scanTypes = [
{ value: 'dependency', label: 'Dependency' },
{ value: 'configuration', label: 'Configuration' },
{ value: 'comprehensive', label: 'Comprehensive' },
] as const;
const severityColor: Record<string, string> = {
critical: 'text-[var(--error)]',
high: 'text-[var(--error)]',
medium: 'text-[var(--warning)]',
low: 'text-[var(--text-tertiary)]',
};
export function SecurityPage() {
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState('');
const [scanType, setScanType] = useState<string>('comprehensive');
const [scanServiceId, setScanServiceId] = useState('');
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects });
const servicesQuery = useQuery({
queryKey: ['services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId),
});
const metricsQuery = useQuery({
queryKey: ['security-metrics', projectId],
queryFn: () => getSecurityMetrics(projectId),
enabled: Boolean(projectId),
refetchInterval: 15_000,
});
const historyQuery = useQuery({
queryKey: ['security-history', projectId],
queryFn: () => getSecurityHistory(projectId),
enabled: Boolean(projectId),
refetchInterval: 15_000,
});
const vulnsQuery = useQuery({
queryKey: ['security-vulns', projectId],
queryFn: () => listVulnerabilities(projectId),
enabled: Boolean(projectId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
};
const scanMutation = useMutation({
mutationFn: () => startSecurityScan({
project_id: projectId,
service_id: scanServiceId || undefined,
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
}),
onSuccess: invalidate,
});
const vulnMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: 'open' | 'resolved' | 'ignored' }) =>
updateVulnerability(id, status),
onSuccess: invalidate,
});
const metrics = metricsQuery.data;
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved');
const pageError =
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
scanMutation.error ?? vulnMutation.error;
return (
<div className="flex flex-col min-h-full">
<SecondaryPageHeader
title="Security"
description="Scan findings, security posture, and compliance status"
/>
<div className="p-8 space-y-8">
{pageError && (
<div className="rounded-[var(--radius-md)] border border-[var(--error)] bg-[var(--error)]/10 px-4 py-3 text-sm text-[var(--error)]">
{pageError instanceof Error ? pageError.message : 'Request failed'}
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
<select
value={projectId}
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] w-56"
>
<option value="">Select project…</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{projectId && (
<>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
<select
value={scanType}
onChange={(e) => setScanType(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
{scanTypes.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Service (optional)</label>
<select
value={scanServiceId}
onChange={(e) => setScanServiceId(e.target.value)}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
>
<option value="">All services</option>
{(servicesQuery.data ?? []).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<button
type="button"
disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
</button>
</>
)}
</div>
{projectId && metrics && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Security score"
value={String(metrics.security_score ?? '—')}
description={metrics.latest_scan?.status === 'never_scanned' ? 'Never scanned' : `Latest scan: ${metrics.latest_scan?.status ?? '—'}`}
icon={Shield}
color={(metrics.security_score ?? 100) < 70 ? 'error' : (metrics.security_score ?? 100) < 90 ? 'warning' : 'success'}
/>
<StatCard
title="Open findings"
value={String(metrics.vulnerabilities?.open ?? 0)}
description={`${metrics.vulnerabilities?.critical ?? 0} critical · ${metrics.vulnerabilities?.high ?? 0} high`}
icon={AlertCircle}
color={metrics.vulnerabilities?.critical ? 'error' : metrics.vulnerabilities?.open ? 'warning' : 'default'}
/>
<StatCard
title="Resolved"
value={String(metrics.vulnerabilities?.resolved ?? 0)}
description="Closed findings"
icon={Check}
/>
<StatCard
title="Compliance"
value={metrics.compliance?.overall_status === 'not_assessed' ? '—' : `${metrics.compliance?.score ?? 0}%`}
description={metrics.compliance?.overall_status ?? 'not assessed'}
icon={FileText}
/>
</div>
)}
{projectId && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<Shield size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
</div>
{vulnsQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : vulns.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
) : (
<div className="space-y-2">
{vulns.map((v) => (
<div
key={v.id}
className="flex items-start justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold uppercase ${severityColor[v.severity ?? ''] ?? 'text-[var(--text-tertiary)]'}`}>
{v.severity}
</span>
<p className="text-sm font-medium text-[var(--text-primary)] truncate">{v.title}</p>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">{v.description}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">
{v.type} · {v.status} · found {v.found_at ? formatRelative(v.found_at) : '—'}
</p>
</div>
{v.status === 'open' && v.id && (
<div className="ml-4 flex items-center gap-2 shrink-0">
<button
type="button"
disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'resolved' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--success)] text-xs text-[var(--success)] disabled:opacity-50"
>
Resolve
</button>
<button
type="button"
disabled={vulnMutation.isPending}
onClick={() => vulnMutation.mutate({ id: v.id!, status: 'ignored' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-xs text-[var(--text-secondary)] disabled:opacity-50"
>
Ignore
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{projectId && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<Clock size={18} className="text-[var(--accent-primary)]" />
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
</div>
{historyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (historyQuery.data ?? []).length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
) : (
<div className="space-y-2">
{(historyQuery.data ?? []).map((scan) => (
<div
key={scan.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-primary)] px-4 py-3"
>
<div>
<p className="text-sm text-[var(--text-primary)]">
{scan.scan_type} · {scan.findings_count ?? 0} findings
</p>
<p className="text-xs text-[var(--text-tertiary)]">
{scan.started_at ? formatRelative(scan.started_at) : '—'}
</p>
</div>
<span className={`text-xs font-medium ${
scan.status === 'completed' ? 'text-[var(--success)]'
: scan.status === 'failed' ? 'text-[var(--error)]'
: 'text-[var(--warning)]'
}`}>
{scan.status}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+56
View File
@@ -1602,3 +1602,59 @@ export async function listHealthResults(): Promise<HAHealthResult[]> {
const payload = await requestJson<{ results?: HAHealthResult[] }>('/ha/health/results');
return payload.results ?? [];
}
export type SecurityScan = components['schemas']['SecurityScan'];
export type Vulnerability = components['schemas']['Vulnerability'];
export type SecurityMetrics = {
vulnerabilities?: {
total?: number; critical?: number; high?: number; medium?: number;
low?: number; open?: number; resolved?: number;
};
latest_scan?: { id?: string; score?: number; scanned_at?: string; status?: string };
compliance?: { overall_status?: string; score?: number; last_assessed?: string };
security_score?: number;
};
export async function startSecurityScan(input: {
project_id: string;
service_id?: string;
scan_type: 'dependency' | 'configuration' | 'comprehensive';
}): Promise<SecurityScan> {
return requestJson<SecurityScan>('/security/scans', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function getSecurityScan(scanId: string): Promise<SecurityScan> {
const payload = await requestJson<{ scan?: SecurityScan } | SecurityScan>(
`/security/scans/${encodeURIComponent(scanId)}`,
);
return 'scan' in payload && payload.scan ? payload.scan : (payload as SecurityScan);
}
export async function getSecurityHistory(projectId: string): Promise<SecurityScan[]> {
const payload = await requestJson<{ scans?: SecurityScan[] }>(
`/projects/${encodeURIComponent(projectId)}/security/history`,
);
return payload.scans ?? [];
}
export async function listVulnerabilities(projectId: string): Promise<Vulnerability[]> {
const payload = await requestJson<{ vulnerabilities?: Vulnerability[] }>(
`/projects/${encodeURIComponent(projectId)}/vulnerabilities`,
);
return payload.vulnerabilities ?? [];
}
export async function updateVulnerability(id: string, status: 'open' | 'resolved' | 'ignored'): Promise<void> {
await requestJson(`/vulnerabilities/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
}
export async function getSecurityMetrics(projectId: string): Promise<SecurityMetrics> {
return requestJson<SecurityMetrics>(`/projects/${encodeURIComponent(projectId)}/security/metrics`);
}