import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs' import { execSync } from 'node:child_process' import path from 'node:path' import os from 'node:os' import { config } from '@/lib/config' import { getDatabase } from '@/lib/db' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type CheckSeverity = 'critical' | 'high' | 'medium' | 'low' export type FixSafety = 'safe' | 'requires-restart' | 'requires-review' | 'manual-only' export interface Check { id: string name: string status: 'pass' | 'fail' | 'warn' detail: string fix: string severity?: CheckSeverity fixSafety?: FixSafety platform?: 'linux' | 'darwin' | 'win32' | 'all' } export interface Category { score: number checks: Check[] } export interface ScanResult { overall: 'secure' | 'hardened' | 'needs-attention' | 'at-risk' score: number timestamp: number categories: { credentials: Category network: Category openclaw: Category runtime: Category os: Category } } // --------------------------------------------------------------------------- // Fix safety map — exported for agent endpoint and UI // --------------------------------------------------------------------------- export 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', } // --------------------------------------------------------------------------- // Severity-weighted scoring // --------------------------------------------------------------------------- const SEVERITY_WEIGHT: Record = { critical: 4, high: 3, medium: 2, low: 1 } const INSECURE_PASSWORDS = new Set([ 'admin', 'password', 'change-me-on-first-login', 'changeme', 'testpass123', ]) export function runSecurityScan(): ScanResult { const credentials = scanCredentials() const network = scanNetwork() const openclaw = scanOpenClaw() const runtime = scanRuntime() const osLevel = scanOS() const categories = { credentials, network, openclaw, runtime, os: osLevel } const allChecks = Object.values(categories).flatMap(c => c.checks) const weightedMax = allChecks.reduce((s, c) => s + SEVERITY_WEIGHT[c.severity ?? 'medium'], 0) const weightedScore = allChecks .filter(c => c.status === 'pass') .reduce((s, c) => s + SEVERITY_WEIGHT[c.severity ?? 'medium'], 0) const score = weightedMax > 0 ? Math.round((weightedScore / weightedMax) * 100) : 0 let overall: ScanResult['overall'] if (score >= 90) overall = 'hardened' else if (score >= 70) overall = 'secure' else if (score >= 40) overall = 'needs-attention' else overall = 'at-risk' return { overall, score, timestamp: Date.now(), categories } } export function readSystemUptimeSeconds(): number | null { try { const value = os.uptime() return Number.isFinite(value) && value >= 0 ? value : null } catch { return null } } function scoreCategory(checks: Check[]): Category { const weightedMax = checks.reduce((s, c) => s + SEVERITY_WEIGHT[c.severity ?? 'medium'], 0) const weightedScore = checks .filter(c => c.status === 'pass') .reduce((s, c) => s + SEVERITY_WEIGHT[c.severity ?? 'medium'], 0) return { score: weightedMax > 0 ? Math.round((weightedScore / weightedMax) * 100) : 100, checks } } // --------------------------------------------------------------------------- // Exec helpers // All exec calls below use only hardcoded string literals — no user input. // --------------------------------------------------------------------------- function tryExec(cmd: string, timeout = 5000): string | null { try { return execSync(cmd, { encoding: 'utf-8', timeout, stdio: ['pipe', 'pipe', 'pipe'] }).trim() } catch { return null } } const execCache = new Map() function cachedExec(key: string, cmd: string, ttlMs = 60000): string | null { const cached = execCache.get(key) if (cached && Date.now() - cached.ts < ttlMs) return cached.value const value = tryExec(cmd) execCache.set(key, { value, ts: Date.now() }) return value } /** * Runs a multi-line script that outputs KEY=VALUE pairs. * Returns a map of key -> value. Used to batch multiple sysctl reads. */ function tryExecBatch(script: string): Record { const out = tryExec(script) if (!out) return {} const result: Record = {} for (const line of out.split('\n')) { const eq = line.indexOf('=') if (eq > 0) result[line.slice(0, eq).trim()] = line.slice(eq + 1).trim() } return result } // --------------------------------------------------------------------------- // Category: Credentials // --------------------------------------------------------------------------- function scanCredentials(): Category { const checks: Check[] = [] const authPass = process.env.AUTH_PASS || '' if (!authPass) { checks.push({ id: 'auth_pass', name: 'Admin password configured', status: 'fail', detail: 'AUTH_PASS is not configured', fix: 'Set AUTH_PASS in .env to a strong password (12+ characters)', severity: 'critical' }) } else if (INSECURE_PASSWORDS.has(authPass)) { checks.push({ id: 'auth_pass', name: 'Admin password strength', status: 'fail', detail: 'AUTH_PASS is set to a known insecure default', fix: 'Change AUTH_PASS to a unique password with 12+ characters', severity: 'critical' }) } else if (authPass.length < 12) { checks.push({ id: 'auth_pass', name: 'Admin password strength', status: 'warn', detail: `AUTH_PASS is only ${authPass.length} characters`, fix: 'Use a password with at least 12 characters', severity: 'critical' }) } else { checks.push({ id: 'auth_pass', name: 'Admin password strength', status: 'pass', detail: 'AUTH_PASS is a strong, non-default password', fix: '', severity: 'critical' }) } const apiKey = process.env.API_KEY || '' checks.push({ id: 'api_key_set', name: 'API key configured', status: apiKey && apiKey !== 'generate-a-random-key' ? 'pass' : 'fail', detail: !apiKey ? 'API_KEY is not set' : apiKey === 'generate-a-random-key' ? 'API_KEY uses the default placeholder' : 'API_KEY is configured', fix: !apiKey || apiKey === 'generate-a-random-key' ? 'Run: bash scripts/generate-env.sh --force' : '', severity: 'critical', }) const envPath = path.join(process.cwd(), '.env') if (existsSync(envPath)) { try { const stat = statSync(envPath) const mode = (stat.mode & 0o777).toString(8) checks.push({ id: 'env_permissions', name: '.env file permissions', status: mode === '600' ? 'pass' : 'warn', detail: `.env permissions are ${mode}`, fix: mode !== '600' ? 'Run: chmod 600 .env' : '', severity: 'medium', fixSafety: 'safe', }) } catch { checks.push({ id: 'env_permissions', name: '.env file permissions', status: 'warn', detail: 'Could not check .env permissions', fix: 'Run: chmod 600 .env', severity: 'medium', fixSafety: 'safe' }) } } return scoreCategory(checks) } // --------------------------------------------------------------------------- // Category: Network // --------------------------------------------------------------------------- function scanNetwork(): Category { const checks: Check[] = [] const allowedHosts = (process.env.MC_ALLOWED_HOSTS || '').trim() const allowAny = process.env.MC_ALLOW_ANY_HOST checks.push({ id: 'allowed_hosts', name: 'Host allowlist configured', status: allowAny === '1' || allowAny === 'true' ? 'fail' : allowedHosts ? 'pass' : 'warn', detail: allowAny === '1' || allowAny === 'true' ? 'MC_ALLOW_ANY_HOST is enabled — any host can connect' : allowedHosts ? `MC_ALLOWED_HOSTS: ${allowedHosts}` : 'MC_ALLOWED_HOSTS is not set', fix: allowAny ? 'Remove MC_ALLOW_ANY_HOST and set MC_ALLOWED_HOSTS instead' : !allowedHosts ? 'Set MC_ALLOWED_HOSTS=localhost,127.0.0.1 in .env' : '', severity: 'high', }) const hsts = process.env.MC_ENABLE_HSTS checks.push({ id: 'hsts_enabled', name: 'HSTS enabled', status: hsts === '1' ? 'pass' : 'warn', detail: hsts === '1' ? 'Strict-Transport-Security header enabled' : 'HSTS is not enabled', fix: hsts !== '1' ? 'Set MC_ENABLE_HSTS=1 in .env (requires HTTPS)' : '', severity: 'medium', }) const cookieSecure = process.env.MC_COOKIE_SECURE checks.push({ id: 'cookie_secure', name: 'Secure cookies', status: cookieSecure === '1' || cookieSecure === 'true' ? 'pass' : 'warn', detail: cookieSecure === '1' || cookieSecure === 'true' ? 'Cookies marked secure' : 'Cookies not explicitly set to secure', fix: !(cookieSecure === '1' || cookieSecure === 'true') ? 'Set MC_COOKIE_SECURE=1 in .env (requires HTTPS)' : '', severity: 'medium', }) const gwHost = config.gatewayHost checks.push({ id: 'gateway_local', name: 'Gateway bound to localhost', status: gwHost === '127.0.0.1' || gwHost === 'localhost' ? 'pass' : 'fail', detail: `Gateway host is ${gwHost}`, fix: gwHost !== '127.0.0.1' && gwHost !== 'localhost' ? 'Set OPENCLAW_GATEWAY_HOST=127.0.0.1 — never expose the gateway publicly' : '', severity: 'critical', }) return scoreCategory(checks) } // --------------------------------------------------------------------------- // Category: OpenClaw // --------------------------------------------------------------------------- function scanOpenClaw(): Category { const checks: Check[] = [] const configPath = config.openclawConfigPath if (!configPath || !existsSync(configPath)) { checks.push({ id: 'config_found', name: 'OpenClaw config found', status: 'warn', detail: 'openclaw.json not found — OpenClaw checks skipped', fix: 'Set OPENCLAW_HOME or OPENCLAW_CONFIG_PATH in .env', severity: 'medium', }) return scoreCategory(checks) } let ocConfig: any try { ocConfig = JSON.parse(readFileSync(configPath, 'utf-8')) } catch (err) { checks.push({ id: 'config_valid', name: 'OpenClaw config valid', status: 'fail', detail: 'openclaw.json could not be parsed', fix: 'Check openclaw.json for syntax errors', severity: 'high', }) return scoreCategory(checks) } try { const stat = statSync(configPath) const mode = (stat.mode & 0o777).toString(8) checks.push({ id: 'config_permissions', name: 'Config file permissions', status: mode === '600' ? 'pass' : 'warn', detail: `openclaw.json permissions are ${mode}`, fix: mode !== '600' ? `Run: chmod 600 ${configPath}` : '', severity: 'medium', fixSafety: 'safe', }) } catch { /* skip */ } const gwAuth = ocConfig?.gateway?.auth const tokenOk = gwAuth?.mode === 'token' && (gwAuth?.token ?? '').trim().length > 0 const passwordOk = gwAuth?.mode === 'password' && (gwAuth?.password ?? '').trim().length > 0 const authOk = tokenOk || passwordOk checks.push({ id: 'gateway_auth', name: 'Gateway authentication', status: authOk ? 'pass' : 'fail', detail: tokenOk ? 'Token auth enabled' : passwordOk ? 'Password auth enabled' : `Auth mode: ${gwAuth?.mode || 'none'} (credential required)`, fix: !authOk ? 'Set gateway.auth.mode to "token" with gateway.auth.token, or "password" with gateway.auth.password' : '', severity: 'critical', }) const gwBind = ocConfig?.gateway?.bind checks.push({ id: 'gateway_bind', name: 'Gateway bind address', status: gwBind === 'loopback' || gwBind === '127.0.0.1' ? 'pass' : 'fail', detail: `Gateway bind: ${gwBind || 'not set'}`, fix: gwBind !== 'loopback' ? 'Set gateway.bind to "loopback" to prevent external access' : '', severity: 'critical', }) const toolsProfile = ocConfig?.tools?.profile checks.push({ id: 'tools_restricted', name: 'Tool permissions restricted', status: toolsProfile && toolsProfile !== 'all' ? 'pass' : 'warn', detail: `Tools profile: ${toolsProfile || 'default'}`, fix: toolsProfile === 'all' ? 'Use a restrictive tools profile like "messaging" or "coding"' : '', severity: 'low', }) const elevated = ocConfig?.elevated?.enabled checks.push({ id: 'elevated_disabled', name: 'Elevated mode disabled', status: elevated !== true ? 'pass' : 'fail', detail: elevated === true ? 'Elevated mode is enabled' : 'Elevated mode is disabled', fix: elevated === true ? 'Set elevated.enabled to false unless explicitly needed' : '', severity: 'high', }) const dmScope = ocConfig?.session?.dmScope checks.push({ id: 'dm_isolation', name: 'DM session isolation', status: dmScope === 'per-channel-peer' ? 'pass' : 'warn', detail: `DM scope: ${dmScope || 'default'}`, fix: dmScope !== 'per-channel-peer' ? 'Set session.dmScope to "per-channel-peer" to prevent context leakage' : '', severity: 'medium', }) const execSecurity = ocConfig?.tools?.exec?.security checks.push({ id: 'exec_restricted', name: 'Exec tool restricted', status: execSecurity === 'deny' ? 'pass' : execSecurity === 'sandbox' ? 'pass' : 'warn', detail: `Exec security: ${execSecurity || 'default'}`, fix: execSecurity !== 'deny' && execSecurity !== 'sandbox' ? 'Set tools.exec.security to "deny" or "sandbox"' : '', severity: 'high', }) const controlUi = ocConfig?.gateway?.controlUi if (controlUi) { checks.push({ id: 'control_ui_device_auth', name: 'Control UI device auth', status: controlUi.dangerouslyDisableDeviceAuth === true ? 'fail' : 'pass', detail: controlUi.dangerouslyDisableDeviceAuth === true ? 'DANGEROUS: dangerouslyDisableDeviceAuth is enabled — device identity checks are bypassed' : 'Control UI device auth is active', fix: controlUi.dangerouslyDisableDeviceAuth === true ? 'Set gateway.controlUi.dangerouslyDisableDeviceAuth to false unless in a break-glass scenario' : '', severity: 'critical', }) checks.push({ id: 'control_ui_insecure_auth', name: 'Control UI secure auth', status: controlUi.allowInsecureAuth === true ? 'warn' : 'pass', detail: controlUi.allowInsecureAuth === true ? 'allowInsecureAuth is enabled — consider HTTPS or localhost-only access' : 'Insecure auth toggle is disabled', fix: controlUi.allowInsecureAuth === true ? 'Set gateway.controlUi.allowInsecureAuth to false, use HTTPS (Tailscale Serve) or localhost' : '', severity: 'high', }) } const fsWorkspaceOnly = ocConfig?.tools?.fs?.workspaceOnly checks.push({ id: 'fs_workspace_only', name: 'Filesystem workspace isolation', status: fsWorkspaceOnly === true ? 'pass' : 'warn', detail: fsWorkspaceOnly === true ? 'File operations restricted to workspace directory' : 'Agents can access files outside the workspace', fix: fsWorkspaceOnly !== true ? 'Set tools.fs.workspaceOnly to true to restrict file access to the workspace' : '', severity: 'medium', }) const toolsDeny = ocConfig?.tools?.deny const dangerousGroups = ['group:automation', 'group:runtime', 'group:fs'] const deniedGroups = Array.isArray(toolsDeny) ? dangerousGroups.filter(g => toolsDeny.includes(g)) : [] checks.push({ id: 'tools_deny_list', name: 'Dangerous tool groups denied', status: deniedGroups.length >= 2 ? 'pass' : deniedGroups.length > 0 ? 'warn' : 'warn', detail: Array.isArray(toolsDeny) && toolsDeny.length > 0 ? `Denied: ${toolsDeny.join(', ')}` : 'No tool deny list configured', fix: deniedGroups.length < 2 ? 'Add tools.deny: ["group:automation", "group:runtime", "group:fs"] for agents that don\'t need them' : '', severity: 'low', }) const logRedact = ocConfig?.logging?.redactSensitive checks.push({ id: 'log_redaction', name: 'Log redaction enabled', status: logRedact ? 'pass' : 'warn', detail: logRedact ? `Log redaction: ${logRedact}` : 'Sensitive data redaction is not configured', fix: !logRedact ? 'Set logging.redactSensitive to "tools" to prevent secrets leaking into logs' : '', severity: 'low', }) const sandboxMode = ocConfig?.agents?.defaults?.sandbox?.mode checks.push({ id: 'sandbox_mode', name: 'Agent sandbox mode', status: sandboxMode === 'all' ? 'pass' : sandboxMode ? 'warn' : 'warn', detail: sandboxMode ? `Sandbox mode: ${sandboxMode}` : 'No default sandbox mode configured', fix: sandboxMode !== 'all' ? 'Set agents.defaults.sandbox.mode to "all" for full isolation (recommended for untrusted inputs)' : '', severity: 'medium', }) const safeBins = ocConfig?.tools?.exec?.safeBins if (Array.isArray(safeBins) && safeBins.length > 0) { const interpreters = ['python', 'python3', 'node', 'bun', 'deno', 'ruby', 'perl', 'bash', 'sh', 'zsh'] const unsafeInterpreters = safeBins.filter((b: string) => interpreters.includes(b)) const safeBinProfiles = ocConfig?.tools?.exec?.safeBinProfiles || {} const unprofiledInterps = unsafeInterpreters.filter((b: string) => !safeBinProfiles[b]) checks.push({ id: 'safe_bins_interpreters', name: 'Safe bins interpreter profiling', status: unprofiledInterps.length === 0 ? 'pass' : 'warn', detail: unprofiledInterps.length > 0 ? `Interpreter binaries without profiles: ${unprofiledInterps.join(', ')}` : 'All interpreter binaries in safeBins have hardened profiles', fix: unprofiledInterps.length > 0 ? `Define tools.exec.safeBinProfiles for: ${unprofiledInterps.join(', ')} — or remove them from safeBins` : '', severity: 'medium', }) } return scoreCategory(checks) } // --------------------------------------------------------------------------- // Category: Runtime // --------------------------------------------------------------------------- function scanRuntime(): Category { const checks: Check[] = [] try { require('@/lib/injection-guard') checks.push({ id: 'injection_guard', name: 'Injection guard active', status: 'pass', detail: 'Prompt and command injection protection is loaded', fix: '', severity: 'critical', }) } catch { checks.push({ id: 'injection_guard', name: 'Injection guard active', status: 'fail', detail: 'Injection guard module not found', fix: 'Ensure src/lib/injection-guard.ts exists and is importable', severity: 'critical', }) } const rlDisabled = process.env.MC_DISABLE_RATE_LIMIT checks.push({ id: 'rate_limiting', name: 'Rate limiting active', status: !rlDisabled ? 'pass' : 'fail', detail: rlDisabled ? 'Rate limiting is disabled' : 'Rate limiting is active', fix: rlDisabled ? 'Remove MC_DISABLE_RATE_LIMIT from .env' : '', severity: 'high', }) const isDocker = existsSync('/.dockerenv') if (isDocker) { checks.push({ id: 'docker_detected', name: 'Running in Docker', status: 'pass', detail: 'Container environment detected', fix: '', severity: 'low', }) } try { const backupDir = path.join(path.dirname(config.dbPath), 'backups') if (existsSync(backupDir)) { const files = readdirSync(backupDir) .filter((f: string) => f.endsWith('.db')) .map((f: string) => { const stat = statSync(path.join(backupDir, f)) return { mtime: stat.mtimeMs } }) .sort((a: any, b: any) => b.mtime - a.mtime) if (files.length > 0) { const ageHours = Math.round((Date.now() - files[0].mtime) / 3600000) checks.push({ id: 'backup_recent', name: 'Recent backup exists', status: ageHours < 24 ? 'pass' : ageHours < 168 ? 'warn' : 'fail', detail: `Latest backup is ${ageHours}h old`, fix: ageHours >= 24 ? 'Enable auto_backup in Settings or run: curl -X POST /api/backup' : '', severity: 'medium', }) } else { checks.push({ id: 'backup_recent', name: 'Recent backup exists', status: 'warn', detail: 'No backups found', fix: 'Enable auto_backup in Settings', severity: 'medium' }) } } else { checks.push({ id: 'backup_recent', name: 'Recent backup exists', status: 'warn', detail: 'No backup directory', fix: 'Enable auto_backup in Settings', severity: 'medium' }) } } catch { checks.push({ id: 'backup_recent', name: 'Recent backup exists', status: 'warn', detail: 'Could not check backups', fix: '', severity: 'medium' }) } try { const db = getDatabase() const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string } | undefined checks.push({ id: 'db_integrity', name: 'Database integrity', status: result?.integrity_check === 'ok' ? 'pass' : 'fail', detail: result?.integrity_check === 'ok' ? 'Integrity check passed' : `Integrity: ${result?.integrity_check || 'unknown'}`, fix: result?.integrity_check !== 'ok' ? 'Database may be corrupted — restore from backup' : '', severity: 'critical', }) } catch { checks.push({ id: 'db_integrity', name: 'Database integrity', status: 'warn', detail: 'Could not run integrity check', fix: '', severity: 'critical' }) } return scoreCategory(checks) } // --------------------------------------------------------------------------- // Category: OS — base + platform-specific hardening checks // --------------------------------------------------------------------------- function scanOS(): Category { const checks: Check[] = [] const platform = os.platform() const isLinux = platform === 'linux' const isDarwin = platform === 'darwin' const isWindows = platform === 'win32' // -- Cross-platform checks -- const uid = process.getuid?.() if (uid !== undefined) { checks.push({ id: 'not_root', name: 'Not running as root', status: uid === 0 ? 'fail' : 'pass', detail: uid === 0 ? 'Process is running as root (UID 0)' : `Running as UID ${uid}`, fix: uid === 0 ? 'Run Mission Control as a non-root user' : '', severity: 'critical', platform: 'all', }) } const nodeVersion = process.versions.node const nodeMajor = parseInt(nodeVersion.split('.')[0], 10) checks.push({ id: 'node_supported', name: 'Node.js version supported', status: nodeMajor >= 20 ? 'pass' : nodeMajor >= 18 ? 'warn' : 'fail', detail: `Node.js v${nodeVersion}`, fix: nodeMajor < 20 ? 'Upgrade to Node.js 20 LTS or later' : '', severity: 'medium', platform: 'all', }) // Node.js elevated capabilities (Linux only) if (isLinux && uid !== undefined && uid !== 0) { const caps = cachedExec('node_caps', 'getcap $(which node) 2>/dev/null') const hasCaps = caps ? caps.includes('=') : false checks.push({ id: 'node_permissions', name: 'Node.js no elevated capabilities', status: hasCaps ? 'warn' : 'pass', detail: hasCaps ? `Node binary has capabilities: ${caps}` : 'Node binary has no special capabilities', fix: hasCaps ? 'Remove capabilities: sudo setcap -r $(which node)' : '', severity: 'medium', platform: 'linux', }) } // Uptime const uptimeSeconds = readSystemUptimeSeconds() if (uptimeSeconds === null) { checks.push({ id: 'uptime', name: 'System reboot freshness', status: 'warn', detail: 'System uptime is unavailable in this runtime environment', fix: '', severity: 'low', platform: 'all', }) } else { const uptimeDays = Math.floor(uptimeSeconds / 86400) checks.push({ id: 'uptime', name: 'System reboot freshness', status: uptimeDays < 30 ? 'pass' : uptimeDays < 90 ? 'warn' : 'fail', detail: `System uptime: ${uptimeDays} day${uptimeDays !== 1 ? 's' : ''}`, fix: uptimeDays >= 30 ? 'Consider rebooting to apply kernel and system updates' : '', severity: 'low', platform: 'all', }) } // NTP sync if (isLinux) { const ntpStatus = cachedExec('ntp_sync', 'timedatectl status 2>/dev/null | grep -i "synchronized\\|ntp" | head -2') const ntpActive = ntpStatus?.toLowerCase().includes('yes') || ntpStatus?.toLowerCase().includes('active') checks.push({ id: 'ntp_sync', name: 'Time synchronization', status: ntpActive ? 'pass' : 'warn', detail: ntpActive ? 'NTP synchronization is active' : 'NTP sync status unknown or inactive', fix: !ntpActive ? 'Enable NTP: sudo timedatectl set-ntp true' : '', severity: 'low', platform: 'linux', }) } else if (isDarwin) { const ntpStatus = cachedExec('ntp_sync', 'systemsetup -getusingnetworktime 2>/dev/null') const ntpActive = ntpStatus?.toLowerCase().includes('on') checks.push({ id: 'ntp_sync', name: 'Time synchronization', status: ntpActive ? 'pass' : 'warn', detail: ntpActive ? 'Network time is enabled' : 'Network time may be disabled', fix: !ntpActive ? 'Enable: sudo systemsetup -setusingnetworktime on' : '', severity: 'low', platform: 'darwin', }) } // -- Firewall -- if (isLinux) { const ufwStatus = tryExec('ufw status 2>/dev/null') const iptablesCount = tryExec('iptables -L -n 2>/dev/null | wc -l') const nftCount = tryExec('nft list ruleset 2>/dev/null | wc -l') const hasUfw = ufwStatus?.includes('active') const hasIptables = iptablesCount ? parseInt(iptablesCount, 10) > 8 : false const hasNft = nftCount ? parseInt(nftCount, 10) > 0 : false checks.push({ id: 'firewall', name: 'Firewall active', status: hasUfw || hasIptables || hasNft ? 'pass' : 'warn', detail: hasUfw ? 'UFW firewall is active' : hasIptables ? 'iptables rules present' : hasNft ? 'nftables rules present' : 'No firewall detected', fix: !hasUfw && !hasIptables && !hasNft ? 'Enable a firewall: sudo ufw enable' : '', severity: 'critical', platform: 'linux', }) } else if (isDarwin) { const pfStatus = tryExec('/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null') const fwEnabled = pfStatus?.includes('enabled') checks.push({ id: 'firewall', name: 'Firewall active', status: fwEnabled ? 'pass' : 'warn', detail: fwEnabled ? 'macOS application firewall is enabled' : 'macOS firewall is disabled', fix: !fwEnabled ? 'Enable firewall: System Settings > Network > Firewall' : '', severity: 'critical', platform: 'darwin', }) } // -- Open ports -- if (isLinux || isDarwin) { const portCmd = isLinux ? 'ss -tlnp 2>/dev/null | tail -n +2 | wc -l' : 'netstat -an 2>/dev/null | grep LISTEN | wc -l' const portCount = tryExec(portCmd) const count = portCount ? parseInt(portCount.trim(), 10) : 0 checks.push({ id: 'open_ports', name: 'Listening ports', status: count <= 10 ? 'pass' : count <= 25 ? 'warn' : 'fail', detail: `${count} listening port${count !== 1 ? 's' : ''} detected`, fix: count > 10 ? 'Review open ports and close unnecessary services' : '', severity: 'medium', platform: isLinux ? 'linux' : 'darwin', }) } // -- SSH hardening (Linux) -- if (isLinux && existsSync('/etc/ssh/sshd_config')) { const sshdConfig = tryExec('grep -i "^PermitRootLogin" /etc/ssh/sshd_config 2>/dev/null') if (sshdConfig !== null) { const allowsRoot = sshdConfig.toLowerCase().includes('yes') checks.push({ id: 'ssh_root', name: 'SSH root login disabled', status: allowsRoot ? 'fail' : 'pass', detail: allowsRoot ? 'SSH allows root login' : 'SSH root login is restricted', fix: allowsRoot ? 'Set PermitRootLogin no in /etc/ssh/sshd_config and restart sshd' : '', severity: 'critical', platform: 'linux', }) } const sshPwAuth = tryExec('grep -i "^PasswordAuthentication" /etc/ssh/sshd_config 2>/dev/null') if (sshPwAuth !== null) { const allowsPw = sshPwAuth.toLowerCase().includes('yes') checks.push({ id: 'ssh_password', name: 'SSH password auth disabled', status: allowsPw ? 'warn' : 'pass', detail: allowsPw ? 'SSH allows password authentication' : 'SSH uses key-based authentication only', fix: allowsPw ? 'Set PasswordAuthentication no in /etc/ssh/sshd_config' : '', severity: 'high', platform: 'linux', }) } } // -- Auto updates -- if (isLinux) { const hasUnattended = existsSync('/etc/apt/apt.conf.d/20auto-upgrades') || existsSync('/etc/yum/yum-cron.conf') || existsSync('/etc/dnf/automatic.conf') checks.push({ id: 'auto_updates', name: 'Automatic security updates', status: hasUnattended ? 'pass' : 'warn', detail: hasUnattended ? 'Automatic update configuration found' : 'No automatic update configuration detected', fix: !hasUnattended ? 'Install unattended-upgrades (Debian/Ubuntu) or dnf-automatic (RHEL/Fedora)' : '', severity: 'medium', platform: 'linux', }) } else if (isDarwin) { const autoUpdate = tryExec('defaults read /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled 2>/dev/null') checks.push({ id: 'auto_updates', name: 'Automatic software updates', status: autoUpdate === '1' ? 'pass' : 'warn', detail: autoUpdate === '1' ? 'Automatic update checks enabled' : 'Automatic update status unknown', fix: autoUpdate !== '1' ? 'Enable in System Settings > General > Software Update' : '', severity: 'medium', platform: 'darwin', }) } // -- Disk encryption -- if (isDarwin) { const fvStatus = tryExec('fdesetup status 2>/dev/null') const encrypted = fvStatus?.includes('On') checks.push({ id: 'disk_encryption', name: 'Disk encryption (FileVault)', status: encrypted ? 'pass' : 'fail', detail: encrypted ? 'FileVault is enabled' : 'FileVault is not enabled', fix: !encrypted ? 'Enable FileVault in System Settings > Privacy & Security' : '', severity: 'high', platform: 'darwin', }) } else if (isLinux) { const luksDevices = tryExec('lsblk -o TYPE 2>/dev/null | grep -c crypt') const hasCrypt = luksDevices ? parseInt(luksDevices, 10) > 0 : false checks.push({ id: 'disk_encryption', name: 'Disk encryption (LUKS)', status: hasCrypt ? 'pass' : 'warn', detail: hasCrypt ? 'Encrypted volumes detected' : 'No LUKS-encrypted volumes detected', fix: !hasCrypt ? 'Consider encrypting data volumes with LUKS' : '', severity: 'high', platform: 'linux', }) } // -- World-writable files -- if (isLinux || isDarwin) { const cwd = process.cwd() const wwFiles = tryExec(`find "${cwd}" -maxdepth 2 -perm -o+w -not -type l 2>/dev/null | head -5`) const wwCount = wwFiles ? wwFiles.split('\n').filter(Boolean).length : 0 checks.push({ id: 'world_writable', name: 'No world-writable app files', status: wwCount === 0 ? 'pass' : 'warn', detail: wwCount === 0 ? 'No world-writable files in app directory' : `${wwCount}+ world-writable file${wwCount > 1 ? 's' : ''} found`, fix: wwCount > 0 ? 'Run: chmod o-w on affected files' : '', severity: 'medium', fixSafety: 'safe', platform: isLinux ? 'linux' : 'darwin', }) } // -- Linux-specific hardening -- if (isLinux) { // Batch read kernel parameters in a single exec const kernelParams = tryExecBatch( 'echo "aslr=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null)"; ' + 'echo "core_pattern=$(cat /proc/sys/kernel/core_pattern 2>/dev/null)"; ' + 'echo "syn_cookies=$(cat /proc/sys/net/ipv4/tcp_syncookies 2>/dev/null)"' ) const aslr = kernelParams['aslr'] checks.push({ id: 'linux_aslr', name: 'Kernel ASLR enabled', status: aslr === '2' ? 'pass' : aslr === '1' ? 'warn' : 'fail', detail: aslr === '2' ? 'Full ASLR randomization active' : aslr === '1' ? 'Partial ASLR — upgrade to full' : aslr ? `ASLR value: ${aslr}` : 'Could not read ASLR status', fix: aslr !== '2' ? 'Set: sysctl -w kernel.randomize_va_space=2' : '', severity: 'critical', fixSafety: 'manual-only', platform: 'linux', }) const corePattern = kernelParams['core_pattern'] || '' const coreToFile = !corePattern.startsWith('|') && corePattern !== '' checks.push({ id: 'linux_core_dumps', name: 'Core dumps restricted', status: coreToFile ? 'warn' : 'pass', detail: coreToFile ? `Core pattern writes to file: ${corePattern}` : 'Core dumps piped to handler or disabled', fix: coreToFile ? 'Restrict core dumps: echo "|/bin/false" > /proc/sys/kernel/core_pattern' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'linux', }) const synCookies = kernelParams['syn_cookies'] checks.push({ id: 'linux_syn_cookies', name: 'TCP SYN cookies enabled', status: synCookies === '1' ? 'pass' : 'warn', detail: synCookies === '1' ? 'SYN cookie protection active' : 'SYN cookies are not enabled', fix: synCookies !== '1' ? 'Set: sysctl -w net.ipv4.tcp_syncookies=1' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'linux', }) // MAC framework const selinux = cachedExec('selinux', 'cat /sys/fs/selinux/enforce 2>/dev/null') const apparmor = cachedExec('apparmor', 'aa-status --enabled 2>/dev/null; echo $?') const hasSELinux = selinux === '1' const hasAppArmor = apparmor?.trim().endsWith('0') checks.push({ id: 'linux_mac_framework', name: 'Mandatory access control', status: hasSELinux || hasAppArmor ? 'pass' : 'warn', detail: hasSELinux ? 'SELinux enforcing' : hasAppArmor ? 'AppArmor active' : 'No MAC framework detected', fix: !hasSELinux && !hasAppArmor ? 'Enable AppArmor or SELinux for mandatory access control' : '', severity: 'high', fixSafety: 'manual-only', platform: 'linux', }) // fail2ban const f2bStatus = cachedExec('fail2ban', 'systemctl is-active fail2ban 2>/dev/null') checks.push({ id: 'linux_fail2ban', name: 'Brute-force protection (fail2ban)', status: f2bStatus === 'active' ? 'pass' : 'warn', detail: f2bStatus === 'active' ? 'fail2ban is active' : 'fail2ban is not running', fix: f2bStatus !== 'active' ? 'Install and enable fail2ban: sudo apt install fail2ban && sudo systemctl enable --now fail2ban' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'linux', }) // /tmp noexec const tmpMount = cachedExec('tmp_mount', 'mount 2>/dev/null | grep " /tmp "') const tmpNoexec = tmpMount?.includes('noexec') checks.push({ id: 'linux_tmp_noexec', name: '/tmp mounted noexec', status: tmpNoexec ? 'pass' : 'warn', detail: tmpNoexec ? '/tmp is mounted with noexec' : '/tmp may allow execution — consider noexec mount', fix: !tmpNoexec ? 'Add noexec,nosuid,nodev to /tmp mount options in /etc/fstab' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'linux', }) } // -- macOS-specific hardening -- if (isDarwin) { const sipStatus = cachedExec('sip', 'csrutil status 2>/dev/null') const sipEnabled = sipStatus?.toLowerCase().includes('enabled') checks.push({ id: 'macos_sip', name: 'System Integrity Protection', status: sipEnabled ? 'pass' : 'fail', detail: sipEnabled ? 'SIP is enabled' : 'SIP is disabled — system files are unprotected', fix: !sipEnabled ? 'Re-enable SIP from Recovery Mode: csrutil enable' : '', severity: 'critical', fixSafety: 'manual-only', platform: 'darwin', }) const gkStatus = cachedExec('gatekeeper', 'spctl --status 2>/dev/null') const gkEnabled = gkStatus?.includes('enabled') checks.push({ id: 'macos_gatekeeper', name: 'Gatekeeper active', status: gkEnabled ? 'pass' : 'warn', detail: gkEnabled ? 'Gatekeeper is enabled' : 'Gatekeeper is disabled', fix: !gkEnabled ? 'Enable Gatekeeper: sudo spctl --master-enable' : '', severity: 'high', fixSafety: 'manual-only', platform: 'darwin', }) const stealthStatus = cachedExec('stealth', '/usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode 2>/dev/null') const stealthEnabled = stealthStatus?.includes('enabled') checks.push({ id: 'macos_stealth_mode', name: 'Firewall stealth mode', status: stealthEnabled ? 'pass' : 'warn', detail: stealthEnabled ? 'Stealth mode is enabled' : 'Stealth mode is disabled', fix: !stealthEnabled ? 'Enable: sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'darwin', }) const remoteLogin = cachedExec('remote_login', 'systemsetup -getremotelogin 2>/dev/null') const remoteOff = remoteLogin?.toLowerCase().includes('off') checks.push({ id: 'macos_remote_login', name: 'Remote login disabled', status: remoteOff ? 'pass' : 'warn', detail: remoteOff ? 'Remote login (SSH) is disabled' : 'Remote login (SSH) is enabled', fix: !remoteOff ? 'Disable if not needed: sudo systemsetup -setremotelogin off' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'darwin', }) const guestAccount = cachedExec('guest', 'defaults read /Library/Preferences/com.apple.loginwindow GuestEnabled 2>/dev/null') const guestDisabled = guestAccount === '0' checks.push({ id: 'macos_guest_account', name: 'Guest account disabled', status: guestDisabled || guestAccount === null ? 'pass' : 'warn', detail: guestDisabled || guestAccount === null ? 'Guest account is disabled' : 'Guest account is enabled', fix: !guestDisabled && guestAccount !== null ? 'Disable: sudo defaults write /Library/Preferences/com.apple.loginwindow GuestEnabled -bool false' : '', severity: 'low', fixSafety: 'manual-only', platform: 'darwin', }) } // -- Windows-specific hardening -- if (isWindows) { const defenderStatus = cachedExec('win_defender', 'powershell -NoProfile -Command "(Get-MpComputerStatus).RealTimeProtectionEnabled" 2>nul') checks.push({ id: 'win_defender', name: 'Windows Defender active', status: defenderStatus === 'True' ? 'pass' : 'fail', detail: defenderStatus === 'True' ? 'Real-time protection is enabled' : 'Windows Defender real-time protection is not active', fix: defenderStatus !== 'True' ? 'Enable Windows Defender real-time protection in Windows Security settings' : '', severity: 'critical', fixSafety: 'manual-only', platform: 'win32', }) const fwProfiles = cachedExec('win_firewall', 'powershell -NoProfile -Command "(Get-NetFirewallProfile | Where-Object {$_.Enabled -eq $true}).Count" 2>nul') const fwCount = fwProfiles ? parseInt(fwProfiles, 10) : 0 checks.push({ id: 'win_firewall', name: 'Windows Firewall active', status: fwCount >= 3 ? 'pass' : fwCount > 0 ? 'warn' : 'fail', detail: fwCount >= 3 ? 'All firewall profiles are active' : `${fwCount} of 3 firewall profiles active`, fix: fwCount < 3 ? 'Enable all firewall profiles in Windows Defender Firewall settings' : '', severity: 'critical', fixSafety: 'manual-only', platform: 'win32', }) const bitlocker = cachedExec('win_bitlocker', 'powershell -NoProfile -Command "(Get-BitLockerVolume -MountPoint C:).ProtectionStatus" 2>nul') checks.push({ id: 'win_bitlocker', name: 'BitLocker encryption', status: bitlocker === 'On' ? 'pass' : 'warn', detail: bitlocker === 'On' ? 'BitLocker is active on C:' : 'BitLocker is not active on C:', fix: bitlocker !== 'On' ? 'Enable BitLocker in Control Panel > BitLocker Drive Encryption' : '', severity: 'high', fixSafety: 'manual-only', platform: 'win32', }) const uac = cachedExec('win_uac', 'powershell -NoProfile -Command "(Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System).EnableLUA" 2>nul') checks.push({ id: 'win_uac', name: 'UAC enabled', status: uac === '1' ? 'pass' : 'fail', detail: uac === '1' ? 'User Account Control is enabled' : 'UAC is disabled', fix: uac !== '1' ? 'Enable UAC in Control Panel > User Account Control Settings' : '', severity: 'high', fixSafety: 'manual-only', platform: 'win32', }) const rdp = cachedExec('win_rdp', "powershell -NoProfile -Command \"(Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server').fDenyTSConnections\" 2>nul") checks.push({ id: 'win_rdp_disabled', name: 'Remote Desktop disabled', status: rdp === '1' ? 'pass' : 'warn', detail: rdp === '1' ? 'Remote Desktop is disabled' : 'Remote Desktop is enabled', fix: rdp !== '1' ? 'Disable RDP if not needed: System Properties > Remote > disable Remote Desktop' : '', severity: 'medium', fixSafety: 'manual-only', platform: 'win32', }) const smb1 = cachedExec('win_smb1', 'powershell -NoProfile -Command "(Get-SmbServerConfiguration).EnableSMB1Protocol" 2>nul') checks.push({ id: 'win_smb1_disabled', name: 'SMBv1 disabled', status: smb1 === 'False' ? 'pass' : 'warn', detail: smb1 === 'False' ? 'SMBv1 is disabled' : 'SMBv1 may be enabled', fix: smb1 !== 'False' ? 'Disable: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force' : '', severity: 'high', fixSafety: 'manual-only', platform: 'win32', }) } return scoreCategory(checks) }