diff --git a/ROADMAP.md b/ROADMAP.md index 854aa65..a3dcaa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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): diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go index 6ce3a49..0a61e26 100644 --- a/app/backend/internal/api/security.go +++ b/app/backend/internal/api/security.go @@ -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) diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 24e83a9..2a724a0 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 07e7c83..cb5f956 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -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 }, diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index a4dcdfe..e68379d 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -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() { ); } + +const scanTypes = [ + { value: 'dependency', label: 'Dependency' }, + { value: 'configuration', label: 'Configuration' }, + { value: 'comprehensive', label: 'Comprehensive' }, +] as const; + +const severityColor: Record = { + 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('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 ( +
+ +
+ {pageError && ( +
+ {pageError instanceof Error ? pageError.message : 'Request failed'} +
+ )} + +
+
+ + +
+ {projectId && ( + <> +
+ + +
+
+ + +
+ + + )} +
+ + {projectId && metrics && ( +
+ + + + +
+ )} + + {projectId && ( +
+
+ +

Findings

+
+ {vulnsQuery.isLoading ? ( +

Loading…

+ ) : vulns.length === 0 ? ( +

No open findings.

+ ) : ( +
+ {vulns.map((v) => ( +
+
+
+ + {v.severity} + +

{v.title}

+
+

{v.description}

+

+ {v.type} · {v.status} · found {v.found_at ? formatRelative(v.found_at) : '—'} +

+
+ {v.status === 'open' && v.id && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ )} + + {projectId && ( +
+
+ +

Scan history

+
+ {historyQuery.isLoading ? ( +

Loading…

+ ) : (historyQuery.data ?? []).length === 0 ? ( +

No scans yet.

+ ) : ( +
+ {(historyQuery.data ?? []).map((scan) => ( +
+
+

+ {scan.scan_type} · {scan.findings_count ?? 0} findings +

+

+ {scan.started_at ? formatRelative(scan.started_at) : '—'} +

+
+ + {scan.status} + +
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 2076f91..5cbb325 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1602,3 +1602,59 @@ export async function listHealthResults(): Promise { 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 { + return requestJson('/security/scans', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function getSecurityScan(scanId: string): Promise { + 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 { + const payload = await requestJson<{ scans?: SecurityScan[] }>( + `/projects/${encodeURIComponent(projectId)}/security/history`, + ); + return payload.scans ?? []; +} + +export async function listVulnerabilities(projectId: string): Promise { + const payload = await requestJson<{ vulnerabilities?: Vulnerability[] }>( + `/projects/${encodeURIComponent(projectId)}/vulnerabilities`, + ); + return payload.vulnerabilities ?? []; +} + +export async function updateVulnerability(id: string, status: 'open' | 'resolved' | 'ignored'): Promise { + await requestJson(`/vulnerabilities/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ status }), + }); +} + +export async function getSecurityMetrics(projectId: string): Promise { + return requestJson(`/projects/${encodeURIComponent(projectId)}/security/metrics`); +}