mission-control/src/lib/security-scan.ts

1075 lines
42 KiB
TypeScript

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<string, FixSafety> = {
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<CheckSeverity, number> = { 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<string, { value: string | null; ts: number }>()
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<string, string> {
const out = tryExec(script)
if (!out) return {}
const result: Record<string, string> = {}
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)
}