'use client' import { useState, useCallback, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Loader } from '@/components/ui/loader' type CheckSeverity = 'critical' | 'high' | 'medium' | 'low' type FixSafety = 'safe' | 'requires-restart' | 'requires-review' | 'manual-only' interface Check { id: string name: string status: 'pass' | 'fail' | 'warn' detail: string fix: string severity?: CheckSeverity fixSafety?: FixSafety } interface Category { score: number checks: Check[] } interface ScanResult { overall: 'secure' | 'hardened' | 'needs-attention' | 'at-risk' score: number timestamp: number categories: { credentials: Category network: Category openclaw: Category runtime: Category os: Category } } // Check IDs that the /api/security-scan/fix endpoint can auto-fix, with safety levels const FIX_SAFETY: Record = { env_permissions: 'safe', config_permissions: 'safe', world_writable: 'safe', hsts_enabled: 'requires-restart', cookie_secure: 'requires-restart', allowed_hosts: 'requires-restart', rate_limiting: 'requires-restart', api_key_set: 'requires-restart', log_redaction: 'requires-restart', dm_isolation: 'requires-restart', fs_workspace_only: 'requires-restart', exec_restricted: 'requires-review', gateway_auth: 'requires-review', gateway_bind: 'requires-review', elevated_disabled: 'requires-review', control_ui_device_auth: 'requires-review', control_ui_insecure_auth: 'requires-review', } const FIXABLE_IDS = new Set(Object.keys(FIX_SAFETY)) const SEVERITY_BADGE: Record = { critical: { label: 'C', className: 'bg-red-500/20 text-red-400' }, high: { label: 'H', className: 'bg-orange-500/20 text-orange-400' }, medium: { label: 'M', className: 'bg-amber-500/20 text-amber-400' }, low: { label: 'L', className: 'bg-blue-500/20 text-blue-300' }, } const STATUS_ICON: Record = { pass: '+', fail: 'x', warn: '!', } const STATUS_COLOR: Record = { pass: 'text-green-400', fail: 'text-red-400', warn: 'text-amber-400', } const OVERALL_COLOR: Record = { hardened: 'text-green-400', secure: 'text-green-300', 'needs-attention': 'text-amber-400', 'at-risk': 'text-red-400', } const CATEGORY_LABELS: Record = { credentials: { label: 'Credentials', icon: 'K' }, network: { label: 'Network', icon: 'N' }, openclaw: { label: 'OpenClaw', icon: 'O' }, runtime: { label: 'Runtime', icon: 'R' }, os: { label: 'OS Security', icon: 'S' }, } export function SecurityScanCard({ compact = false, autoScan = false }: { compact?: boolean; autoScan?: boolean }) { const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [expandedCategory, setExpandedCategory] = useState(null) const [copiedFix, setCopiedFix] = useState(null) const [fixing, setFixing] = useState(null) // 'all' or a check id const [fixResult, setFixResult] = useState<{ attempted: number fixed: number failed: number remaining: number remainingAutoFixable: number remainingManual: number note?: string } | null>(null) const copyFix = useCallback(async (fix: string, checkId: string) => { try { await navigator.clipboard.writeText(fix) setCopiedFix(checkId) setTimeout(() => setCopiedFix(null), 2000) } catch { // Fallback const el = document.createElement('textarea') el.value = fix document.body.appendChild(el) el.select() document.execCommand('copy') document.body.removeChild(el) setCopiedFix(checkId) setTimeout(() => setCopiedFix(null), 2000) } }, []) const runScan = useCallback(async () => { setLoading(true) setError(null) setFixResult(null) try { const res = await fetch('/api/security-scan') if (!res.ok) { setError(res.status === 401 ? 'Admin access required' : 'Scan failed') return } setResult(await res.json()) } catch { setError('Failed to connect') } finally { setLoading(false) } }, []) const runFix = useCallback(async (ids?: string[]) => { const fixKey = ids?.length === 1 ? ids[0] : 'all' setFixing(fixKey) setFixResult(null) try { const res = await fetch('/api/security-scan/fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: ids ? JSON.stringify({ ids }) : '{}', }) if (!res.ok) { setFixResult({ attempted: 0, fixed: 0, failed: 1, remaining: 0, remainingAutoFixable: 0, remainingManual: 0, note: res.status === 401 ? 'Admin access required' : 'Fix failed' }) return } const data = await res.json() setFixResult({ attempted: data.attempted ?? data.fixed + data.failed, fixed: data.fixed ?? 0, failed: data.failed ?? 0, remaining: data.remaining ?? 0, remainingAutoFixable: data.remainingAutoFixable ?? 0, remainingManual: data.remainingManual ?? 0, note: data.note, }) // Re-scan after fixes setTimeout(() => runScan(), 1500) } catch { setFixResult({ attempted: 0, fixed: 0, failed: 1, remaining: 0, remainingAutoFixable: 0, remainingManual: 0, note: 'Network error' }) } finally { setTimeout(() => setFixing(null), 1500) } }, [runScan]) useEffect(() => { if (autoScan && !result && !loading && !error) { runScan() } }, [autoScan, result, loading, error, runScan]) if (!result && !loading && !error) { return (

Run a comprehensive security scan of your installation

Checks credentials, network config, OpenClaw hardening, and runtime security

) } if (loading) { return (
) } if (error) { return (

{error}

) } if (!result) return null const failingChecks = Object.values(result.categories).flatMap((cat) => cat.checks.filter((check) => check.status !== 'pass')) const autoFixableChecks = failingChecks.filter((check) => FIXABLE_IDS.has(check.id)) const manualChecks = failingChecks.filter((check) => !FIXABLE_IDS.has(check.id)) return (
{/* Score header */}
{result.score}
{result.overall.replace('-', ' ')}
Security score
{/* Score bar */}
= 90 ? 'bg-green-400' : result.score >= 70 ? 'bg-green-300' : result.score >= 40 ? 'bg-amber-400' : 'bg-red-400' }`} style={{ width: `${result.score}%` }} />
{/* Issue summary + Fix All */} {(() => { const totalFailing = failingChecks.length return totalFailing > 0 ? (

{totalFailing} issue{totalFailing > 1 ? 's' : ''} found

{autoFixableChecks.length} auto-fixable, {manualChecks.length} manual/review

) : null })()} {/* Fix result feedback */} {fixResult && (
0 ? 'bg-amber-500/10 border-amber-500/20 text-amber-300' : 'bg-green-500/10 border-green-500/20 text-green-300'}`}> {fixResult.attempted > 0 && {fixResult.attempted} auto-fix attempt{fixResult.attempted > 1 ? 's' : ''}. } {fixResult.fixed > 0 && {fixResult.fixed} issue{fixResult.fixed > 1 ? 's' : ''} fixed. } {fixResult.failed > 0 && {fixResult.failed} failed. } {fixResult.remaining > 0 && {fixResult.remaining} issue{fixResult.remaining > 1 ? 's' : ''} remain. } {fixResult.remainingManual > 0 && {fixResult.remainingManual} still need manual action or review. } {fixResult.note && {fixResult.note}}
)} {/* Categories */}
{Object.entries(result.categories).map(([key, cat]) => { const meta = CATEGORY_LABELS[key] const isExpanded = expandedCategory === key const failing = cat.checks.filter(c => c.status !== 'pass') return (
{isExpanded && (
{cat.checks.map(check => (
[{STATUS_ICON[check.status]}]
{check.name} {check.severity && ( {SEVERITY_BADGE[check.severity].label} )}

{check.detail}

{check.fix && check.status !== 'pass' && (

Fix: {check.fix}

{FIXABLE_IDS.has(check.id) && ( )}
)}
))}
)}
) })}
) }