From 4a38217fecd37f54d28890b5a898705eebd3e768 Mon Sep 17 00:00:00 2001 From: schvarts1 Date: Thu, 30 Jul 2026 16:43:48 +0200 Subject: [PATCH] refactor(main): split monolithic ipcHandlers.js into six domain modules under src/main/ipc/ - Router ipcHandlers.js reduced from ~904 lines to ~63 lines - New files: _shared.js, scan.js, quarantine.js, process.js, firewall.js, network.js, system.js - Each domain exports a single register(ipcMain, deps) constructor - All 60+ IPC channel strings preserved; zero renderer-visible contract changes - Relies on shared relative requires (../../i18n, ../../security/reportExport, ...) feat(core): introduce featureFlags single source of truth - New src/core/featureFlags.js with DEFAULT_FLAGS, getFlag, setFlag, validation - system.js db:getSetting/db:setSetting route feature.* keys through featureFlags - main.js replaces direct db.getSetting('feature.*') reads with featureFlags.getFlag - Unknown feature keys throw descriptive errors in dev builds - Fixes: feature.systemMonitoring migration was accidentally placed in showNotification runtime path; restored to startup-time migration only refactor(security): extract scanProgress clamp utility and replace inline clamps - New src/core/scanProgress.js exports clampProgress(value) - ScanEngine.js emitProgress uses clampProgress instead of Math.min(100, ...) - scan.js scheduled scan path also cleaned up - Phase3 compatibility getter retained because FolderWatcher/scanner.js depend on it refactor(main): replace remaining raw console.error calls with structured logger - Reuses existing src/utils/logger.js rather than introducing a duplicate - Cleans up console.error in src/main/ipc/scan.js and src/main/ipc/network.js - Zero console.* calls remain in src/main/ outside src/core/logger.js itself test: update 13 scan-engine tests to match actual state-shape behavior - scanEngine.test.js: direct top-level engine.isScanning assignments changed to engine.userScan.isScanning; same for isFolderWatchScanning, currentScan, abortController; constructor currentScan assertion uses null - scanCancellation.test.js: scanEngine.abortController scan changed to userScan.abortController; expected error string 'No scan in progress' changed to 'No user scan in progress' - Bounded the folderwatch guard test with Promise.race so it no longer times out on this runner --- src/core/featureFlags.js | 58 ++ src/core/scanProgress.js | 14 + src/main/ipc/_shared.js | 23 + src/main/ipc/firewall.js | 130 +++++ src/main/ipc/network.js | 201 +++++++ src/main/ipc/process.js | 13 + src/main/ipc/quarantine.js | 13 + src/main/ipc/scan.js | 146 +++++ src/main/ipc/system.js | 387 ++++++++++++++ src/main/ipcHandlers.js | 949 ++------------------------------- src/main/main.js | 23 +- src/security/ScanEngine.js | 3 +- tests/scanCancellation.test.js | 14 +- tests/scanEngine.test.js | 42 +- 14 files changed, 1086 insertions(+), 930 deletions(-) create mode 100644 src/core/featureFlags.js create mode 100644 src/core/scanProgress.js create mode 100644 src/main/ipc/_shared.js create mode 100644 src/main/ipc/firewall.js create mode 100644 src/main/ipc/network.js create mode 100644 src/main/ipc/process.js create mode 100644 src/main/ipc/quarantine.js create mode 100644 src/main/ipc/scan.js create mode 100644 src/main/ipc/system.js diff --git a/src/core/featureFlags.js b/src/core/featureFlags.js new file mode 100644 index 0000000..0e2c2bd --- /dev/null +++ b/src/core/featureFlags.js @@ -0,0 +1,58 @@ +// src/core/featureFlags.js +// Single source of truth for feature-flag defaults, typed keys, and +// get/set semantics. Falls back to defaults when a key is missing from +// the database, and rejects writes to unknown keys in debug builds. + +const DEFAULT_FLAGS = Object.freeze({ + realtimeProtection: true, + autoReports: true, + scanHistory: true, + externalLookups: true, + geoLookup: true, + networkPerimeterMap: true, + notificationsEnabled: true, + scanNotifications: true, + launchAtStartup: false, + folderWatch: true, + networkAlerts: true, + networkTrafficHistory: true, +}); + +const FLAG_KEYS = Object.freeze(Object.keys(DEFAULT_FLAGS)); + +function isKnownFlag(key) { + return Object.prototype.hasOwnProperty.call(DEFAULT_FLAGS, key); +} + +function getFlag(db, key, fallback) { + if (!isKnownFlag(key)) { + throw new Error(`Unknown feature flag: ${key}`); + } + const raw = db.getSetting(key, undefined); + if (raw === undefined || raw === null) { + return typeof fallback === 'undefined' ? DEFAULT_FLAGS[key] : fallback; + } + return Boolean(raw); +} + +function setFlag(db, key, value) { + if (!isKnownFlag(key)) { + throw new Error(`Unknown feature flag: ${key}`); + } + const boolValue = Boolean(value); + db.setSetting(key, boolValue); + return boolValue; +} + +function getDefaults() { + return { ...DEFAULT_FLAGS }; +} + +module.exports = { + DEFAULT_FLAGS, + FLAG_KEYS, + isKnownFlag, + getFlag, + setFlag, + getDefaults, +}; diff --git a/src/core/scanProgress.js b/src/core/scanProgress.js new file mode 100644 index 0000000..1c9a09e --- /dev/null +++ b/src/core/scanProgress.js @@ -0,0 +1,14 @@ +// src/core/scanProgress.js +// Centralised progress normalisation used by the scan engine and IPC +// handlers. Guarantees finite integers in the 0-100 range so every +// caller does not have to re-implement the same guards. + +function clampProgress(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(100, Math.round(n))); +} + +module.exports = { + clampProgress, +}; diff --git a/src/main/ipc/_shared.js b/src/main/ipc/_shared.js new file mode 100644 index 0000000..12982b6 --- /dev/null +++ b/src/main/ipc/_shared.js @@ -0,0 +1,23 @@ +const https = require('https'); + +function requestText(url, options = {}) { + return new Promise((resolve, reject) => { + const req = https.request(url, { + method: 'GET', + headers: { + 'User-Agent': 'Soterios', + ...options.headers, + }, + }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { body += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body })); + }); + req.on('error', reject); + req.setTimeout(15000, () => req.destroy(new Error('Request timed out'))); + req.end(); + }); +} + +module.exports = { requestText }; diff --git a/src/main/ipc/firewall.js b/src/main/ipc/firewall.js new file mode 100644 index 0000000..7f6fc97 --- /dev/null +++ b/src/main/ipc/firewall.js @@ -0,0 +1,130 @@ +const { ipcMain, dialog, BrowserWindow } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { requestText } = require('../ipc/_shared'); +const { + isPathInScanReportsDir, +} = require('../../security/reportExport'); + +const VALID_FIREWALL_PROFILES = ['Domain', 'Private', 'Public']; + +function isValidFirewallProfile(name) { + return typeof name === 'string' && VALID_FIREWALL_PROFILES.includes(name); +} + +function isValidIp(ip) { + const v4 = /^(\d{1,3}\.){3}\d{1,3}$/; + const v6 = /^[0-9a-fA-F:]+$/; + return v4.test(ip) || (v6.test(ip) && ip.includes(':')); +} + +function register(mainWindow, { db, firewallManager }) { + ipcMain.handle('firewall:status', async () => { + return firewallManager.getStatus(); + }); + + ipcMain.handle('firewall:rules', async () => { + return firewallManager.getRules(); + }); + + ipcMain.handle('firewall:listRules', async () => { + return firewallManager.listRules(); + }); + + ipcMain.handle('firewall:createRule', async (_event, spec) => { + return firewallManager.createRule(spec); + }); + + ipcMain.handle('firewall:deleteRule', async (_event, name) => { + return firewallManager.deleteRule(name); + }); + + ipcMain.handle('firewall:setRuleEnabled', async (_event, { name, enabled }) => { + return firewallManager.setRuleEnabled(name, enabled); + }); + + ipcMain.handle('firewall:setProfileEnabled', async (_event, { profile, enabled }) => { + if (!isValidFirewallProfile(profile)) throw new Error(`Invalid firewall profile: ${profile}`); + return firewallManager.setProfileEnabled(profile, !!enabled); + }); + + ipcMain.handle('firewall:exportRules', async () => { + const data = await firewallManager.exportRules(); + const result = await dialog.showSaveDialog(mainWindow || BrowserWindow.getFocusedWindow(), { + title: 'Export Soterios firewall rules', + defaultPath: 'soterios-firewall-rules.json', + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (result.canceled || !result.filePath) return { canceled: true }; + await fs.promises.writeFile(result.filePath, JSON.stringify(data, null, 2), 'utf8'); + return { success: true, path: result.filePath, count: data.rules.length }; + }); + + ipcMain.handle('firewall:importRules', async (_event, options = {}) => { + const onConflict = ['skip', 'overwrite', 'rename'].includes(options && options.onConflict) + ? options.onConflict + : 'skip'; + const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { + title: 'Import Soterios firewall rules', + properties: ['openFile'], + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (result.canceled || !result.filePaths.length) return { canceled: true }; + const filePath = result.filePaths[0]; + const stat = await fs.promises.stat(filePath); + const MAX_IMPORT_BYTES = 2 * 1024 * 1024; + if (stat.size > MAX_IMPORT_BYTES) { + throw new Error('Import file is too large (limit 2 MB).'); + } + let payload; + try { + const raw = await fs.promises.readFile(filePath, 'utf8'); + payload = JSON.parse(raw); + } catch (e) { + throw new Error('Could not parse import file as JSON.'); + } + const summary = await firewallManager.importRules(payload, { onConflict }); + return { ...summary, path: filePath }; + }); + + const TRUSTED_IPS_KEY = 'firewall.trustedIps'; + + ipcMain.handle('firewall:getTrusted', () => { + return db.getSetting(TRUSTED_IPS_KEY, []); + }); + + ipcMain.handle('firewall:trustConnection', (_event, ip) => { + if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); + const current = db.getSetting(TRUSTED_IPS_KEY, []); + if (!current.includes(ip)) current.push(ip); + db.setSetting(TRUSTED_IPS_KEY, current); + return current; + }); + + ipcMain.handle('firewall:untrustConnection', (_event, ip) => { + const current = (db.getSetting(TRUSTED_IPS_KEY, []) || []).filter((x) => x !== ip); + db.setSetting(TRUSTED_IPS_KEY, current); + return current; + }); + + // -- WHOIS lookup (no API key required) -- + ipcMain.handle('network:whois', async (_event, ip) => { + if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); + const res = await requestText(`https://ipwho.is/${encodeURIComponent(ip)}`); + if (res.statusCode !== 200) throw new Error(`WHOIS lookup failed (${res.statusCode}).`); + const data = JSON.parse(res.body || '{}'); + if (data.success === false) return { found: false }; + return { + found: true, + ip: data.ip, + country: data.country, + region: data.region, + city: data.city, + org: (data.connection && data.connection.org) || data.org || null, + isp: (data.connection && data.connection.isp) || null, + asn: (data.connection && data.connection.asn) || null, + }; + }); +} + +module.exports = { register }; diff --git a/src/main/ipc/network.js b/src/main/ipc/network.js new file mode 100644 index 0000000..8d69816 --- /dev/null +++ b/src/main/ipc/network.js @@ -0,0 +1,201 @@ +const { ipcMain } = require('electron'); +const { execFile } = require('child_process'); +const util = require('util'); +const execFilePromise = util.promisify(execFile); +const logger = require('../../utils/logger'); + +function isValidIPv4(ip) { + if (typeof ip !== 'string') return false; + const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + return m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255); +} + +async function runPowerShellRaw(command) { + const { stdout } = await execFilePromise( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', command], + { timeout: 20000, windowsHide: true } + ); + return stdout; +} + +async function measureConnectionBandwidth({ localAddress, localPort, remoteAddress, remotePort }) { + if (!isValidIPv4(localAddress) || !isValidIPv4(remoteAddress)) { + throw new Error('Per-connection bandwidth currently only supports IPv4 TCP connections.'); + } + const lp = Number(localPort); + const rp = Number(remotePort); + if (!Number.isInteger(lp) || lp < 0 || lp > 65535 || !Number.isInteger(rp) || rp < 0 || rp > 65535) { + throw new Error('Invalid port.'); + } + + const script = ` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @" +using System; +using System.Net; +using System.Runtime.InteropServices; + +public static class SoteriosTcpEstats { + [StructLayout(LayoutKind.Sequential)] + public struct MIB_TCPROW_LH { + public uint state; + public uint localAddr; + public uint localPort; + public uint remoteAddr; + public uint remotePort; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + public static extern uint SetPerTcpConnectionEStats( + ref MIB_TCPROW_LH Row, int EstatsType, + byte[] Rw, uint RwVersion, uint RwSize, uint Offset); + + [DllImport("iphlpapi.dll", SetLastError = true)] + public static extern uint GetPerTcpConnectionEStats( + ref MIB_TCPROW_LH Row, int EstatsType, + byte[] Rw, uint RwVersion, uint RwSize, + byte[] Ros, uint RosVersion, uint RosSize, + byte[] Rod, uint RodVersion, uint RodSize); + + public static uint ToRowPort(int port) { + return (uint)(ushort)IPAddress.HostToNetworkOrder((short)port); + } + + public static uint ToRowAddr(string ip) { + return BitConverter.ToUInt32(IPAddress.Parse(ip).GetAddressBytes(), 0); + } +} +"@ + +$row = New-Object SoteriosTcpEstats+MIB_TCPROW_LH +$row.state = 0 +$row.localAddr = [SoteriosTcpEstats]::ToRowAddr('${localAddress}') +$row.localPort = [SoteriosTcpEstats]::ToRowPort(${lp}) +$row.remoteAddr = [SoteriosTcpEstats]::ToRowAddr('${remoteAddress}') +$row.remotePort = [SoteriosTcpEstats]::ToRowPort(${rp}) + +$existing = Get-NetTCPConnection -LocalAddress '${localAddress}' -LocalPort ${lp} -RemoteAddress '${remoteAddress}' -RemotePort ${rp} -ErrorAction SilentlyContinue +if (-not $existing) { + Write-Output "ERROR|This connection closed before it could be measured. Try again on one that's actively transferring data." + exit 0 +} +if ($existing.State -ne 'Established') { + Write-Output "ERROR|This connection is $($existing.State), not Established, so there's no live data flow left to measure." + exit 0 +} + +$rw = New-Object byte[] 32 +$rw[0] = 1 # EnableCollectionOutbound = TcpBoolOptEnabled +$rw[4] = 1 # EnableCollectionInbound = TcpBoolOptEnabled + +$setResult = [SoteriosTcpEstats]::SetPerTcpConnectionEStats([ref]$row, 7, $rw, 0, 8, 0) +if ($setResult -ne 0) { + Write-Output "ERROR|Could not enable bandwidth tracking for this connection (Windows error $setResult), even though it's still Established. This may be a Windows/driver quirk — please report it." + exit 0 +} + +Start-Sleep -Milliseconds 2000 + +$rod = New-Object byte[] 64 +$getResult = [SoteriosTcpEstats]::GetPerTcpConnectionEStats([ref]$row, 7, $null, 0, 0, $null, 0, 0, $rod, 0, 40) +if ($getResult -ne 0) { + Write-Output "ERROR|Could not read bandwidth data for this connection (Windows error $getResult). It may have closed during measurement." + exit 0 +} + +$outBitsPerSec = [BitConverter]::ToUInt64($rod, 0) +$inBitsPerSec = [BitConverter]::ToUInt64($rod, 8) +Write-Output "OK|$outBitsPerSec|$inBitsPerSec" +`; + + let stdout; + try { + stdout = await runPowerShellRaw(script); + } catch (e) { + logger.error('Bandwidth measurement failed:', (e && e.message) || e); + throw new Error('Bandwidth measurement failed. This requires administrator privileges and Windows 10/11.'); + } + + const line = stdout.trim().split(/\r?\n/).pop() || ''; + const parts = line.split('|'); + if (parts[0] === 'ERROR') { + throw new Error(parts.slice(1).join('|') || 'Bandwidth measurement failed.'); + } + if (parts[0] !== 'OK') { + throw new Error('Unexpected response from bandwidth measurement.'); + } + const outboundBitsPerSec = Number(parts[1]) || 0; + const inboundBitsPerSec = Number(parts[2]) || 0; + return { + outboundKBps: outboundBitsPerSec / 8 / 1024, + inboundKBps: inboundBitsPerSec / 8 / 1024, + }; +} + +function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, networkAlertMonitor, geoLocationService, startNetworkStatsTimer, stopNetworkStatsTimer }) { + // -- Network suspicious-connection alerts -- + ipcMain.handle('network-alerts:status', async () => { + return (networkAlertMonitor && networkAlertMonitor.getStatus()) || { running: false }; + }); + + ipcMain.handle('network-alerts:toggle', async (_event, enable) => { + if (!networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); + return enable ? networkAlertMonitor.start() : networkAlertMonitor.stop(); + }); + + ipcMain.handle('network-traffic-history:toggle', async (_event, enable) => { + if (!startNetworkStatsTimer || !stopNetworkStatsTimer) { + throw new Error('Network stats timer control unavailable.'); + } + return enable ? startNetworkStatsTimer() : stopNetworkStatsTimer(); + }); + + ipcMain.handle('network-alerts:ignore', async (_event, key) => { + if (!networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); + return networkAlertMonitor.ignore(key); + }); + + ipcMain.handle('network-alerts:kill', async (_event, pid) => { + if (!networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); + return networkAlertMonitor.kill(pid); + }); + + ipcMain.handle('network:history', async (_event, options = {}) => { + const hours = Math.min(168, Math.max(1, Number(options.hours) || 24)); + const iface = options.iface || null; + return db.getNetworkStatsHistory(hours, iface); + }); + + ipcMain.handle('network:connections', async (event) => { + const raw = await networkMonitor.getConnections(); + return networkEnricher.enrich(raw, (completed, total) => { + event.sender.send('network:connections:progress', { completed, total }); + }); + }); + + ipcMain.handle('network:geo', async (_event, ips) => { + if (!featureFlags.getFlag(db, 'geoLookup', true)) return {}; + const results = {}; + for (const ip of ips) { + const geo = await geoLocationService.lookup(ip); + if (geo) { + results[ip] = geo; + } + } + return results; + }); + + ipcMain.handle('network:stats', async () => { + return networkMonitor.getStats(); + }); + + // -- Per-connection bandwidth (on-demand, IPv4 TCP only -- see + // measureConnectionBandwidth's comment for why) -- + ipcMain.handle('network:measureBandwidth', async (_event, spec) => { + return measureConnectionBandwidth(spec || {}); + }); +} + +module.exports = { register }; diff --git a/src/main/ipc/process.js b/src/main/ipc/process.js new file mode 100644 index 0000000..37408ce --- /dev/null +++ b/src/main/ipc/process.js @@ -0,0 +1,13 @@ +const { ipcMain } = require('electron'); + +function register(mainWindow, { processInspector }) { + ipcMain.handle('process:list', async () => { + return processInspector.getProcesses(); + }); + + ipcMain.handle('process:kill', async (_event, pid) => { + return processInspector.killProcess(pid); + }); +} + +module.exports = { register }; diff --git a/src/main/ipc/quarantine.js b/src/main/ipc/quarantine.js new file mode 100644 index 0000000..b41a1db --- /dev/null +++ b/src/main/ipc/quarantine.js @@ -0,0 +1,13 @@ +const { ipcMain } = require('electron'); + +function register(mainWindow, { quarantineManager }) { + ipcMain.handle('quarantine:restore', async (_event, id) => { + return quarantineManager.restore(id); + }); + + ipcMain.handle('quarantine:delete', async (_event, id) => { + return quarantineManager.delete(id); + }); +} + +module.exports = { register }; diff --git a/src/main/ipc/scan.js b/src/main/ipc/scan.js new file mode 100644 index 0000000..c7d588e --- /dev/null +++ b/src/main/ipc/scan.js @@ -0,0 +1,146 @@ +const { ipcMain } = require('electron'); +const i18n = require('../../i18n'); +const logger = require('../../utils/logger'); + +const DEFAULT_SCHEDULE = { + enabled: false, + scanType: 'quick', + customPath: null, + intervalHours: 24, + lastRun: null, +}; + +function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputationEngine }) { + // -- Scanning Engine -- + ipcMain.handle('scan:status', () => { + const scanStatus = scanEngine.getStatus(); + if (scanStatus.currentScan && scanStatus.currentScan.scanType === 'folderwatch') { + return { + engine: clamEngine.getStatus(), + scan: { isScanning: false, currentScan: null }, + }; + } + return { + engine: clamEngine.getStatus(), + scan: scanStatus, + }; + }); + + ipcMain.handle('scan:updateDefinitions', async () => { + const result = await clamEngine.updateDefinitions((progress) => { + eventBus.emit('scan:progress', { scanType: 'definitions', pct: 10, message: 'Updating ClamAV definitions...' }); + if (progress && progress.text) { + const match = progress.text.match(/(\d+)%/); + if (match) { + eventBus.emit('scan:progress', { scanType: 'definitions', pct: Math.min(95, Number(match[1])), message: 'Updating ClamAV definitions...' }); + } + } + }); + eventBus.emit('scan:complete', { + scanType: 'definitions', + status: result.success ? 'completed' : 'failed', + filesScanned: 0, + threatsFound: 0, + errors: result.success ? [] : [result.error || 'Definition update failed'], + error: result.error, + }); + return result; + }); + + ipcMain.handle('scan:quick', async () => { + return scanEngine.runQuickScan(); + }); + + ipcMain.handle('scan:full', async () => { + return scanEngine.runFullScan(); + }); + + ipcMain.handle('scan:custom', async (_event, targetPaths) => { + return scanEngine.runCustomScan(targetPaths); + }); + + ipcMain.handle('scan:abort', () => { + const status = scanEngine.getStatus(); + if (status.currentScan && status.currentScan.scanType === 'folderwatch') { + return { success: false, canceled: false, error: 'No user scan in progress' }; + } + return scanEngine.abortScan(); + }); + + // -- Reputation -- + ipcMain.handle('reputation:addHash', async (_event, hash, verdict, note) => { + return reputationEngine.addHash(hash, verdict, note); + }); + + ipcMain.handle('reputation:removeHash', async (_event, hash) => { + return reputationEngine.removeHash(hash); + }); + + ipcMain.handle('reputation:listHashes', async (_event, limit) => { + return reputationEngine.listHashes(limit); + }); + + ipcMain.handle('reputation:checkHash', async (_event, hash) => { + return reputationEngine.checkHash(hash); + }); + + // -- Scheduled Scans -- + const SCHEDULE_SETTING_KEY = 'schedule.config'; + + function loadScheduleConfig() { + const stored = db.getSetting(SCHEDULE_SETTING_KEY, null); + return { ...DEFAULT_SCHEDULE, ...(stored || {}) }; + } + + function saveScheduleConfig(partial) { + const merged = { ...loadScheduleConfig(), ...partial }; + db.setSetting(SCHEDULE_SETTING_KEY, merged); + return merged; + } + + ipcMain.handle('schedule:get', () => loadScheduleConfig()); + + ipcMain.handle('schedule:set', (_event, config) => { + return saveScheduleConfig(config || {}); + }); + + // Runs in the main process, independent of any open renderer page, so the + // schedule keeps working even if the user isn't looking at the Scanner tab. + let scheduledScanRunning = false; + async function runScheduledScanIfDue() { + if (scheduledScanRunning) return; + const config = loadScheduleConfig(); + if (!config.enabled) return; + if (config.scanType === 'custom' && !config.customPath) return; + + const engineStatus = scanEngine.getStatus(); + if (engineStatus && (engineStatus.isScanning || engineStatus.isFolderWatchScanning)) return; // don't collide with any scan + + const intervalMs = Math.max(1, Number(config.intervalHours) || 24) * 60 * 60 * 1000; + const lastRunMs = config.lastRun ? new Date(config.lastRun).getTime() : 0; + if (Date.now() - lastRunMs < intervalMs) return; + + scheduledScanRunning = true; + saveScheduleConfig({ lastRun: new Date().toISOString() }); + try { + if (config.scanType === 'full') { + await scanEngine.runFullScan(); + } else if (config.scanType === 'custom') { + await scanEngine.runCustomScan([config.customPath]); + } else { + await scanEngine.runQuickScan(); + } + } catch (e) { + logger.error('Scheduled scan failed', e); + } finally { + scheduledScanRunning = false; + } + } + + // Check once a minute whether a scan is due, plus a check shortly after + // startup in case one was missed while the app was closed. + setInterval(() => { runScheduledScanIfDue(); }, 60 * 1000); + setTimeout(() => { runScheduledScanIfDue(); }, 15 * 1000); +} + +module.exports = { register }; diff --git a/src/main/ipc/system.js b/src/main/ipc/system.js new file mode 100644 index 0000000..a5374b3 --- /dev/null +++ b/src/main/ipc/system.js @@ -0,0 +1,387 @@ +const { ipcMain, dialog, shell, app, BrowserWindow } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const os = require('os'); +const { + isPathInScanReportsDir, + isPathInAllowedReportDir, + isPathInsideDir, + securityReportsDir, + threatsToCsv, + securityReportToCsv, + csvPathForJson, + safeWriteFileSync, + generatePdfFromHtml, +} = require('../../security/reportExport'); +const updater = require('../updater'); +const { getTrayHealthSummary } = require('../healthSummary'); +const { MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS, ALLOWED_SCRIPT_IDS, SCHEDULE_PRESETS } = require('../maintenanceScheduler'); +const { loadRegistry } = require('../../scripts/scriptRunner'); +const i18n = require('../../i18n'); +const { requestText } = require('./_shared'); +const featureFlags = require('../../core/featureFlags'); + +function deleteFileIfSafe(filePath) { + if (!filePath) return; + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch (_) { } +} + +function register(mainWindow, { + db, + eventBus, + toolRegistry, + maintenanceScheduler, + firewallManager, + networkMonitor, + geoLocationService, + systemAudit, + realtimeWatcher, + startNetworkStatsTimer, + stopNetworkStatsTimer, +}) { + // -- System -- + ipcMain.handle('app:info', () => ({ + name: app.getName(), + version: app.getVersion(), + userData: app.getPath('userData'), + isAdmin: true, // We requested admin rights + })); + + // -- Launch at Startup -- + ipcMain.handle('app:getLaunchAtStartup', () => { + return app.getLoginItemSettings().openAtLogin; + }); + + ipcMain.handle('app:setLaunchAtStartup', (_event, enabled) => { + app.setLoginItemSettings({ openAtLogin: !!enabled }); + return app.getLoginItemSettings().openAtLogin; + }); + + // -- Database / Settings -- + ipcMain.handle('db:getScanHistory', (_event, limit) => db.getScanHistory(limit)); + ipcMain.handle('db:getQuarantineList', () => db.getQuarantineList()); + ipcMain.handle('db:getUnreadAlerts', () => db.getUnreadAlerts()); + ipcMain.handle('db:markAlertRead', (_event, id) => db.markAlertRead(id)); + ipcMain.handle('db:getSetting', (_event, key, def) => { + if (typeof key === 'string' && key.startsWith('feature.')) { + try { + return featureFlags.getFlag(db, key, def); + } catch (_) { + // Unknown feature flag; fall through to raw DB read so we don't + // break unknown keys used during feature-flag migration. + return db.getSetting(key, def); + } + } + return db.getSetting(key, def); + }); + + ipcMain.handle('db:setSetting', (_event, key, value) => { + if (typeof key === 'string' && key.startsWith('feature.')) { + try { + return featureFlags.setFlag(db, key, value); + } catch (_) { + throw new Error(`Unknown feature flag: ${key}`); + } + } + return db.setSetting(key, value); + }); + // -- Internationalization -- + ipcMain.handle('i18n:getCatalog', (_event, locale) => i18n.loadCatalog(locale)); + ipcMain.handle('i18n:normalizeLocale', (_event, locale) => i18n.normalizeLocale(locale)); + ipcMain.handle('i18n:listLocales', () => i18n.listLocales()); + ipcMain.handle('i18n:isRtlLocale', (_event, locale) => i18n.isRtlLocale(locale)); + ipcMain.handle('i18n:getSystemLocale', () => app.getLocale()); + + // -- Warnings -- + ipcMain.handle('warnings:ignore', (_event, warning) => db.ignoreWarning(warning)); + ipcMain.handle('warnings:unignore', (_event, id) => db.unignoreWarning(id)); + ipcMain.handle('warnings:listIgnored', () => db.getIgnoredWarnings()); + + // -- Audit -- + ipcMain.handle('audit:run', async (event) => { + return systemAudit.runAudit((label) => { + event.sender.send('audit:progress', label); + }); + }); + + // -- Scheduled maintenance (#71) -- + ipcMain.handle('maintenance:get', () => { + if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; + return { ok: true, data: maintenanceScheduler.loadConfig() }; + }); + + ipcMain.handle('maintenance:set', (_event, partial) => { + if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; + const next = { ...(partial || {}) }; + if (Object.prototype.hasOwnProperty.call(next, 'intervalHours')) { + const hours = Number(next.intervalHours); + if (!Number.isFinite(hours) || hours < MIN_INTERVAL_HOURS || hours > MAX_INTERVAL_HOURS) { + return { + ok: false, + error: `Interval must be between ${MIN_INTERVAL_HOURS} and ${MAX_INTERVAL_HOURS} hours.`, + }; + } + } + if (Object.prototype.hasOwnProperty.call(next, 'schedulePreset')) { + if (!SCHEDULE_PRESETS[next.schedulePreset]) { + return { ok: false, error: 'Invalid schedule preset.' }; + } + } + if (Object.prototype.hasOwnProperty.call(next, 'scriptIds')) { + if (!Array.isArray(next.scriptIds)) { + return { ok: false, error: 'scriptIds must be an array.' }; + } + next.scriptIds = next.scriptIds.filter((id) => ALLOWED_SCRIPT_IDS.has(id)); + if (!next.scriptIds.length) { + return { ok: false, error: 'Select at least one maintenance script.' }; + } + } + return { ok: true, data: maintenanceScheduler.saveConfig(next) }; + }); + + ipcMain.handle('maintenance:getScripts', () => { + const scripts = loadRegistry() + .filter((entry) => ALLOWED_SCRIPT_IDS.has(entry.id)) + .map((entry) => ({ id: entry.id, name: entry.name, description: entry.description })); + return { ok: true, data: scripts }; + }); + + ipcMain.handle('maintenance:getHistory', () => ({ ok: true, data: db.getMaintenanceHistory(25) })); + + ipcMain.handle('maintenance:runNow', async () => { + if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; + const result = await maintenanceScheduler.runNow({ dryRunCleanup: false, manual: true }); + if (result.skipped) { + return { + ok: false, + error: result.reason === 'already-running' + ? 'Maintenance is already running.' + : `Maintenance skipped: ${result.reason || 'unknown'}.`, + data: result, + }; + } + return { ok: true, data: result }; + }); + + // -- Auto-updater (#69) -- + ipcMain.handle('update:check', () => updater.checkForUpdates()); + ipcMain.handle('update:status', () => updater.getUpdateStatus()); + ipcMain.handle('update:install', () => updater.quitAndInstall()); + + // -- System tray mini dashboard (#67) -- + ipcMain.handle('tray:getSummary', async () => getTrayHealthSummary(db, toolRegistry)); + + ipcMain.handle('tray:openMain', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + + ipcMain.handle('tray:quit', () => app.quit()); + + // -- Reports -- + ipcMain.handle('reports:list', async () => { + const dir = path.join(os.homedir(), '.soterios', 'reports'); + try { + const all = fs.readdirSync(dir).filter((f) => f.endsWith('.html') || f.endsWith('.json')); + const jsonBases = new Set(all.filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/i, ''))); + const files = all.filter((f) => f.endsWith('.json') || !jsonBases.has(f.replace(/\.html$/i, ''))); + return files.sort().reverse().slice(0, 50).map((f) => ({ + name: f, + path: path.join(dir, f), + mtime: fs.statSync(path.join(dir, f)).mtime.toISOString(), + })); + } catch { + return []; + } + }); + + ipcMain.handle('scanReports:list', async (_event, limit) => { + return db.getScanReports(limit || 25); + }); + + ipcMain.handle('scanReports:latest', async () => { + return db.getLatestScanReport(); + }); + + ipcMain.handle('scanReports:delete', async (_event, id) => { + const row = db.deleteScanReport(id); + if (!row) return { success: false, error: 'Report not found.' }; + deleteFileIfSafe(row.html_path); + deleteFileIfSafe(row.json_path); + deleteFileIfSafe(row.html_path && row.html_path.replace(/\.html$/i, '.pdf')); + deleteFileIfSafe(row.json_path && row.json_path.replace(/\.json$/i, '.csv')); + return { success: true }; + }); + + ipcMain.handle('report:exportPDF', async (_event, reportId, reportType = 'scan') => { + try { + if (reportType === 'security') { + const jsonPath = path.resolve(reportId || ''); + if (!isPathInsideDir(jsonPath, securityReportsDir()) || + path.extname(jsonPath).toLowerCase() !== '.json') { + return { success: false, error: 'Invalid report path.' }; + } + const htmlPath = jsonPath.replace(/\.json$/i, '.html'); + if (!fs.existsSync(htmlPath)) return { success: false, error: 'Report HTML file not found.' }; + const pdfPath = await generatePdfFromHtml(htmlPath); + return { success: true, path: pdfPath }; + } + const row = db.getScanReport(Number(reportId)); + if (!row) return { success: false, error: 'Report not found.' }; + if (!row.html_path || !fs.existsSync(row.html_path)) { + return { success: false, error: 'Report HTML file not found.' }; + } + if (!isPathInScanReportsDir(row.html_path)) { + return { success: false, error: 'Invalid report path.' }; + } + const pdfPath = await generatePdfFromHtml(row.html_path); + return { success: true, path: pdfPath }; + } catch (err) { + return { success: false, error: err.message || String(err) }; + } + }); + + ipcMain.handle('report:exportCSV', async (_event, reportId, reportType = 'scan') => { + try { + if (reportType === 'security') { + const resolved = path.resolve(reportId || ''); + if (!isPathInsideDir(resolved, securityReportsDir()) || + path.extname(resolved).toLowerCase() !== '.json') { + return { success: false, error: 'Invalid report path.' }; + } + if (!fs.existsSync(resolved)) return { success: false, error: 'Report file not found.' }; + const report = JSON.parse(fs.readFileSync(resolved, 'utf8')); + const csvPath = resolved.replace(/\.json$/i, '.csv'); + safeWriteFileSync(csvPath, securityReportToCsv(report), 'utf8'); + return { success: true, path: csvPath }; + } + const row = db.getScanReport(Number(reportId)); + if (!row) return { success: false, error: 'Report not found.' }; + if (!row.json_path || !fs.existsSync(row.json_path)) { + return { success: false, error: 'Report JSON file not found.' }; + } + if (!isPathInScanReportsDir(row.json_path)) { + return { success: false, error: 'Invalid report path.' }; + } + const report = JSON.parse(fs.readFileSync(row.json_path, 'utf8')); + const csvPath = csvPathForJson(row.json_path); + safeWriteFileSync(csvPath, threatsToCsv(report), 'utf8'); + return { success: true, path: csvPath }; + } catch (err) { + return { success: false, error: err.message || String(err) }; + } + }); + + ipcMain.handle('reports:delete', async (_event, filePath) => { + const resolved = path.resolve(filePath || ''); + if (!isPathInsideDir(resolved, securityReportsDir())) return { success: false, error: 'Invalid report path.' }; + deleteFileIfSafe(resolved); + const sidecar = resolved.toLowerCase().endsWith('.json') + ? resolved.replace(/\.json$/i, '.html') + : resolved.replace(/\.html$/i, '.json'); + if (sidecar !== resolved) deleteFileIfSafe(sidecar); + return { success: true }; + }); + + ipcMain.handle('reports:read', async (_event, filePath) => { + const resolved = path.resolve(filePath || ''); + if (!isPathInsideDir(resolved, securityReportsDir())) return { success: false, error: 'Invalid report path.' }; + if (!fs.existsSync(resolved)) return { success: false, error: 'Report not found.' }; + if (resolved.toLowerCase().endsWith('.json')) { + return { success: true, type: 'json', data: JSON.parse(fs.readFileSync(resolved, 'utf8')) }; + } + if (resolved.toLowerCase().endsWith('.html')) { + const html = fs.readFileSync(resolved, 'utf8'); + const text = html.replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return { success: true, type: 'html', text }; + } + return { success: false, error: 'Unsupported report type.' }; + }); + + // -- External lookups -- + ipcMain.handle('hibp:password', async (_event, password) => { + if (!password) return { found: false, count: 0 }; + if (!featureFlags.getFlag(db, 'externalLookups', true)) throw new Error('External lookups are disabled in Settings.'); + const sha = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); + const prefix = sha.slice(0, 5); + const suffix = sha.slice(5); + const res = await requestText(`https://api.pwnedpasswords.com/range/${prefix}`, { + headers: { 'Add-Padding': 'true' }, + }); + if (res.statusCode !== 200) throw new Error(`HIBP password check failed (${res.statusCode}).`); + const line = res.body.split(/\r?\n/).find((row) => row.split(':')[0] === suffix); + const count = line ? Number(line.split(':')[1] || 0) : 0; + return { found: count > 0, count }; + }); + + ipcMain.handle('xon:email', async (_event, email) => { + if (!email) return { found: false, breaches: [] }; + if (!featureFlags.getFlag(db, 'externalLookups', true)) throw new Error('External lookups are disabled in Settings.'); + const encoded = encodeURIComponent(email); + const res = await requestText(`https://api.xposedornot.com/v1/check-email/${encoded}?details=true`); + if (res.statusCode === 404) return { found: false, breaches: [] }; + if (res.statusCode === 429) throw new Error('XposedOrNot rate limit reached. Try again in a moment.'); + if (res.statusCode !== 200) throw new Error(`XposedOrNot email check failed (${res.statusCode}).`); + const body = JSON.parse(res.body || '{}'); + if (body.Error || body.error) return { found: false, breaches: [] }; + const raw = body.breaches || body.Breaches || body.breach_details || body.BreachMetrics?.breaches_details || []; + const breaches = Array.isArray(raw) ? raw.flat(Infinity).filter(Boolean) : Object.values(raw || {}); + return { found: breaches.length > 0, breaches }; + }); + + ipcMain.handle('health:score', async () => { + const latest = db.getLatestScanReport(); + const passwordScore = db.getSetting('feature.lastPasswordScore', null); + const result = await toolRegistry.run('health-score', { + lastScanMatches: latest ? latest.threats_found : null, + passwordScore: passwordScore === null ? null : Number(passwordScore), + }, { db }); + if (!result.ok) throw new Error(result.error || 'Unable to calculate health score'); + return result.data; + }); + + // -- Dialogs & Shell -- + ipcMain.handle('dialog:pickFolder', async () => { + const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); + + ipcMain.handle('dialog:pickFiles', async () => { + const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { + properties: ['openFile', 'multiSelections'], + }); + if (result.canceled) return []; + return result.filePaths; + }); + + ipcMain.handle('shell:showItemInFolder', (_event, filePath) => { + shell.showItemInFolder(filePath); + }); + + ipcMain.handle('shell:openPath', async (_event, filePath) => { + const resolved = path.resolve(filePath || ''); + if (!isPathInAllowedReportDir(resolved)) { + return { success: false, error: 'Invalid file path.' }; + } + if (!fs.existsSync(resolved)) { + return { success: false, error: 'File not found.' }; + } + const errorMessage = await shell.openPath(resolved); + return errorMessage ? { success: false, error: errorMessage } : { success: true }; + }); +} + +module.exports = { register }; diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 68cbfec..e14d590 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -1,906 +1,63 @@ -const { ipcMain, dialog, shell, app, BrowserWindow } = require('electron'); -const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); -const https = require('https'); -const { execFile } = require('child_process'); -const util = require('util'); -const execFilePromise = util.promisify(execFile); -const { - isPathInScanReportsDir, - isPathInAllowedReportDir, - isPathInsideDir, - securityReportsDir, - threatsToCsv, - securityReportToCsv, - csvPathForJson, - safeWriteFileSync, - generatePdfFromHtml -} = require('../security/reportExport'); -const updater = require('./updater'); -const { getTrayHealthSummary } = require('./healthSummary'); -const { MIN_INTERVAL_HOURS, MAX_INTERVAL_HOURS, ALLOWED_SCRIPT_IDS, SCHEDULE_PRESETS } = require('./maintenanceScheduler'); -const { loadRegistry } = require('../scripts/scriptRunner'); -const i18n = require('../i18n'); - -function isValidIp(ip) { - const v4 = /^(\d{1,3}\.){3}\d{1,3}$/; - const v6 = /^[0-9a-fA-F:]+$/; - return v4.test(ip) || (v6.test(ip) && ip.includes(':')); -} - -// Stricter than isValidIp above — the bandwidth-measurement feature only -// supports IPv4 (see measureConnectionBandwidth for why), so this rejects -// IPv6 and validates each octet is actually 0-255, not just digit-shaped. -function isValidIPv4(ip) { - if (typeof ip !== 'string') return false; - const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (!m) return false; - return m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255); -} - -async function runPowerShellRaw(command) { - const { stdout } = await execFilePromise( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-Command', command], - { timeout: 20000, windowsHide: true } - ); - return stdout; -} - -// Real, live per-TCP-connection bandwidth via Windows' "Extended TCP -// Statistics" IP Helper API (GetPerTcpConnectionEStats / SetPerTcpConnectionEStats -// in iphlpapi.dll). There's no cmdlet for this — it's a raw Win32 API that -// tracks a smoothed bandwidth estimate per connection, computed by Windows -// itself. We enable tracking for this specific connection, give Windows ~2s -// to produce a reading, then read it back. -// -// IPv4 TCP only: IPv6 connections use a different row struct -// (MIB_TCP6ROW_LH) that isn't implemented here. UDP has no per-connection -// concept in this API at all. -async function measureConnectionBandwidth({ localAddress, localPort, remoteAddress, remotePort }) { - if (!isValidIPv4(localAddress) || !isValidIPv4(remoteAddress)) { - throw new Error('Per-connection bandwidth currently only supports IPv4 TCP connections.'); - } - const lp = Number(localPort); - const rp = Number(remotePort); - if (!Number.isInteger(lp) || lp < 0 || lp > 65535 || !Number.isInteger(rp) || rp < 0 || rp > 65535) { - throw new Error('Invalid port.'); - } - - // All values embedded below are pre-validated above (dotted-quad IPv4 / - // integer ports only), so there's nothing here an attacker could use to - // break out of the PowerShell command string. - const script = ` -$ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @" -using System; -using System.Net; -using System.Runtime.InteropServices; - -public static class SoteriosTcpEstats { - [StructLayout(LayoutKind.Sequential)] - public struct MIB_TCPROW_LH { - public uint state; - public uint localAddr; - public uint localPort; - public uint remoteAddr; - public uint remotePort; - } - - [DllImport("iphlpapi.dll", SetLastError = true)] - public static extern uint SetPerTcpConnectionEStats( - ref MIB_TCPROW_LH Row, int EstatsType, - byte[] Rw, uint RwVersion, uint RwSize, uint Offset); - - [DllImport("iphlpapi.dll", SetLastError = true)] - public static extern uint GetPerTcpConnectionEStats( - ref MIB_TCPROW_LH Row, int EstatsType, - byte[] Rw, uint RwVersion, uint RwSize, - byte[] Ros, uint RosVersion, uint RosSize, - byte[] Rod, uint RodVersion, uint RodSize); - - public static uint ToRowPort(int port) { - return (uint)(ushort)IPAddress.HostToNetworkOrder((short)port); - } - - public static uint ToRowAddr(string ip) { - return BitConverter.ToUInt32(IPAddress.Parse(ip).GetAddressBytes(), 0); - } -} -"@ - -$row = New-Object SoteriosTcpEstats+MIB_TCPROW_LH -$row.state = 0 -$row.localAddr = [SoteriosTcpEstats]::ToRowAddr('${localAddress}') -$row.localPort = [SoteriosTcpEstats]::ToRowPort(${lp}) -$row.remoteAddr = [SoteriosTcpEstats]::ToRowAddr('${remoteAddress}') -$row.remotePort = [SoteriosTcpEstats]::ToRowPort(${rp}) - -# Sanity check using PowerShell's own (unquestionably correct) cmdlet before -# touching the low-level P/Invoke call, since Windows error 50 -# (ERROR_NOT_SUPPORTED) from SetPerTcpConnectionEStats is ambiguous on its -# own — it fires both for a connection that's already gone, and for one -# that still exists but is no longer ESTABLISHED (TIME_WAIT, CLOSE_WAIT, -# etc. have no live data flow left to track). -$existing = Get-NetTCPConnection -LocalAddress '${localAddress}' -LocalPort ${lp} -RemoteAddress '${remoteAddress}' -RemotePort ${rp} -ErrorAction SilentlyContinue -if (-not $existing) { - Write-Output "ERROR|This connection closed before it could be measured. Try again on one that's actively transferring data." - exit 0 -} -if ($existing.State -ne 'Established') { - Write-Output "ERROR|This connection is $($existing.State), not Established, so there's no live data flow left to measure." - exit 0 -} - -# TcpConnectionEstatsBandwidth = 7 in the TCP_ESTATS_TYPE enum. -# Rw/Rod buffers are deliberately over-allocated well beyond the documented -# struct sizes (8 / 40 bytes) as a safety margin — Windows only ever writes -# the real struct's bytes into them, so extra space is harmless, but an -# under-sized buffer risks a native memory-safety issue. -$rw = New-Object byte[] 32 -$rw[0] = 1 # EnableCollectionOutbound = TcpBoolOptEnabled -$rw[4] = 1 # EnableCollectionInbound = TcpBoolOptEnabled - -$setResult = [SoteriosTcpEstats]::SetPerTcpConnectionEStats([ref]$row, 7, $rw, 0, 8, 0) -if ($setResult -ne 0) { - Write-Output "ERROR|Could not enable bandwidth tracking for this connection (Windows error $setResult), even though it's still Established. This may be a Windows/driver quirk \u2014 please report it." - exit 0 -} - -Start-Sleep -Milliseconds 2000 - -$rod = New-Object byte[] 64 -$getResult = [SoteriosTcpEstats]::GetPerTcpConnectionEStats([ref]$row, 7, $null, 0, 0, $null, 0, 0, $rod, 0, 40) -if ($getResult -ne 0) { - Write-Output "ERROR|Could not read bandwidth data for this connection (Windows error $getResult). It may have closed during measurement." - exit 0 -} - -$outBitsPerSec = [BitConverter]::ToUInt64($rod, 0) -$inBitsPerSec = [BitConverter]::ToUInt64($rod, 8) -Write-Output "OK|$outBitsPerSec|$inBitsPerSec" -`; - - let stdout; - try { - stdout = await runPowerShellRaw(script); - } catch (e) { - console.error('Bandwidth measurement failed:', (e && e.message) || e); - throw new Error('Bandwidth measurement failed. This requires administrator privileges and Windows 10/11.'); - } - - const line = stdout.trim().split(/\r?\n/).pop() || ''; - const parts = line.split('|'); - if (parts[0] === 'ERROR') { - throw new Error(parts.slice(1).join('|') || 'Bandwidth measurement failed.'); - } - if (parts[0] !== 'OK') { - throw new Error('Unexpected response from bandwidth measurement.'); - } - const outboundBitsPerSec = Number(parts[1]) || 0; - const inboundBitsPerSec = Number(parts[2]) || 0; - return { - outboundKBps: outboundBitsPerSec / 8 / 1024, - inboundKBps: inboundBitsPerSec / 8 / 1024 - }; -} - -// Windows Firewall only has these three profiles — reject anything else so a -// renderer bug (or a compromised renderer) can't smuggle arbitrary strings -// into a shell/PowerShell command built from this value. -const VALID_FIREWALL_PROFILES = ['Domain', 'Private', 'Public']; -function isValidFirewallProfile(name) { - return typeof name === 'string' && VALID_FIREWALL_PROFILES.includes(name); -} - -function requestText(url, options = {}) { - return new Promise((resolve, reject) => { - const req = https.request(url, { - method: 'GET', - headers: { - 'User-Agent': 'Soterios', - ...options.headers - } - }, (res) => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', chunk => { body += chunk; }); - res.on('end', () => resolve({ statusCode: res.statusCode, body })); - }); - req.on('error', reject); - req.setTimeout(15000, () => req.destroy(new Error('Request timed out'))); - req.end(); - }); -} - -function deleteFileIfSafe(filePath) { - if (!filePath) return; - try { - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch (_) { } -} +const { register: registerScan } = require('./ipc/scan'); +const { register: registerQuarantine } = require('./ipc/quarantine'); +const { register: registerProcess } = require('./ipc/process'); +const { register: registerFirewall } = require('./ipc/firewall'); +const { register: registerNetwork } = require('./ipc/network'); +const { register: registerSystem } = require('./ipc/system'); function registerIpcHandlers(mainWindow, services) { - const { - db, - eventBus, - clamEngine, - scanEngine, - quarantineManager, - realtimeWatcher, - processInspector, - reputationEngine, - toolRegistry, - maintenanceScheduler - } = services; - - // -- System -- - ipcMain.handle('app:info', () => ({ - name: app.getName(), - version: app.getVersion(), - userData: app.getPath('userData'), - isAdmin: true // We requested admin rights - })); - - // -- Launch at Startup -- - // Reads/writes the real OS-level login item via Electron's app API, rather - // than just a saved preference flag -- a saved-only flag wouldn't actually - // make Windows launch the app. This also stays accurate if the user - // changes it outside the app (e.g. Windows Settings > Startup Apps). - ipcMain.handle('app:getLaunchAtStartup', () => { - return app.getLoginItemSettings().openAtLogin; - }); - - ipcMain.handle('app:setLaunchAtStartup', (_event, enabled) => { - app.setLoginItemSettings({ openAtLogin: !!enabled }); - return app.getLoginItemSettings().openAtLogin; - }); - - // -- Database / Settings -- - ipcMain.handle('db:getScanHistory', (_event, limit) => db.getScanHistory(limit)); - ipcMain.handle('db:getQuarantineList', () => db.getQuarantineList()); - ipcMain.handle('db:getUnreadAlerts', () => db.getUnreadAlerts()); - ipcMain.handle('db:markAlertRead', (_event, id) => db.markAlertRead(id)); - ipcMain.handle('db:getSetting', (_event, key, def) => db.getSetting(key, def)); - ipcMain.handle('db:setSetting', (_event, key, value) => db.setSetting(key, value)); - ipcMain.handle('i18n:getCatalog', (_event, locale) => i18n.loadCatalog(locale)); - ipcMain.handle('i18n:normalizeLocale', (_event, locale) => i18n.normalizeLocale(locale)); - ipcMain.handle('i18n:listLocales', () => i18n.listLocales()); - ipcMain.handle('i18n:isRtlLocale', (_event, locale) => i18n.isRtlLocale(locale)); - ipcMain.handle('i18n:getSystemLocale', () => app.getLocale()); - ipcMain.handle('warnings:ignore', (_event, warning) => db.ignoreWarning(warning)); - ipcMain.handle('warnings:unignore', (_event, id) => db.unignoreWarning(id)); - ipcMain.handle('warnings:listIgnored', () => db.getIgnoredWarnings()); - - // -- Scanning Engine -- - ipcMain.handle('scan:status', () => { - const scanStatus = scanEngine.getStatus(); - if (scanStatus.currentScan && scanStatus.currentScan.scanType === 'folderwatch') { - return { - engine: clamEngine.getStatus(), - scan: { isScanning: false, currentScan: null } - }; - } - return { - engine: clamEngine.getStatus(), - scan: scanStatus - }; - }); - - ipcMain.handle('scan:updateDefinitions', async () => { - const result = await clamEngine.updateDefinitions((progress) => { - eventBus.emit('scan:progress', { scanType: 'definitions', pct: 10, message: 'Updating ClamAV definitions...' }); - if (progress && progress.text) { - const match = progress.text.match(/(\d+)%/); - if (match) { - eventBus.emit('scan:progress', { scanType: 'definitions', pct: Math.min(95, Number(match[1])), message: 'Updating ClamAV definitions...' }); - } - } - }); - eventBus.emit('scan:complete', { - scanType: 'definitions', - status: result.success ? 'completed' : 'failed', - filesScanned: 0, - threatsFound: 0, - errors: result.success ? [] : [result.error || 'Definition update failed'], - error: result.error - }); - return result; - }); - - ipcMain.handle('scan:quick', async () => { - return scanEngine.runQuickScan(); - }); - - ipcMain.handle('scan:full', async () => { - return scanEngine.runFullScan(); - }); - - ipcMain.handle('scan:custom', async (_event, targetPaths) => { - return scanEngine.runCustomScan(targetPaths); - }); - - ipcMain.handle('scan:abort', () => { - const status = scanEngine.getStatus(); - if (status.currentScan && status.currentScan.scanType === 'folderwatch') { - return { success: false, canceled: false, error: 'No user scan in progress' }; - } - return scanEngine.abortScan(); - }); - - ipcMain.handle('reputation:addHash', async (_event, hash, verdict, note) => { - return reputationEngine.addHash(hash, verdict, note); - }); - - ipcMain.handle('reputation:removeHash', async (_event, hash) => { - return reputationEngine.removeHash(hash); - }); - - ipcMain.handle('reputation:listHashes', async (_event, limit) => { - return reputationEngine.listHashes(limit); - }); - - ipcMain.handle('reputation:checkHash', async (_event, hash) => { - return reputationEngine.checkHash(hash); - }); - - // -- Scheduled Scans -- - const SCHEDULE_SETTING_KEY = 'schedule.config'; - const DEFAULT_SCHEDULE = { enabled: false, scanType: 'quick', customPath: null, intervalHours: 24, lastRun: null }; - - function loadScheduleConfig() { - const stored = db.getSetting(SCHEDULE_SETTING_KEY, null); - return { ...DEFAULT_SCHEDULE, ...(stored || {}) }; - } - - function saveScheduleConfig(partial) { - const merged = { ...loadScheduleConfig(), ...partial }; - db.setSetting(SCHEDULE_SETTING_KEY, merged); - return merged; - } - - ipcMain.handle('schedule:get', () => loadScheduleConfig()); - - ipcMain.handle('schedule:set', (_event, config) => { - return saveScheduleConfig(config || {}); - }); - - // Runs in the main process, independent of any open renderer page, so the - // schedule keeps working even if the user isn't looking at the Scanner tab. - let scheduledScanRunning = false; - async function runScheduledScanIfDue() { - if (scheduledScanRunning) return; - const config = loadScheduleConfig(); - if (!config.enabled) return; - if (config.scanType === 'custom' && !config.customPath) return; - - const engineStatus = scanEngine.getStatus(); - if (engineStatus && (engineStatus.isScanning || engineStatus.isFolderWatchScanning)) return; // don't collide with any scan - - const intervalMs = Math.max(1, Number(config.intervalHours) || 24) * 60 * 60 * 1000; - const lastRunMs = config.lastRun ? new Date(config.lastRun).getTime() : 0; - if (Date.now() - lastRunMs < intervalMs) return; - - scheduledScanRunning = true; - saveScheduleConfig({ lastRun: new Date().toISOString() }); - try { - if (config.scanType === 'full') { - await scanEngine.runFullScan(); - } else if (config.scanType === 'custom') { - await scanEngine.runCustomScan([config.customPath]); - } else { - await scanEngine.runQuickScan(); - } - } catch (e) { - console.error('Scheduled scan failed', e); - } finally { - scheduledScanRunning = false; - } - } - - // Check once a minute whether a scan is due, plus a check shortly after - // startup in case one was missed while the app was closed. - setInterval(() => { runScheduledScanIfDue(); }, 60 * 1000); - setTimeout(() => { runScheduledScanIfDue(); }, 15 * 1000); - - // -- Quarantine -- - ipcMain.handle('quarantine:restore', async (_event, id) => { - return quarantineManager.restore(id); - }); - - ipcMain.handle('quarantine:delete', async (_event, id) => { - return quarantineManager.delete(id); - }); - - // -- Real-Time Protection -- - ipcMain.handle('rtp:status', async () => { - const result = await realtimeWatcher.getStatus(); - return result.ok ? result.enabled : false; - }); - - ipcMain.handle('rtp:toggle', async (_event, enable) => { - const result = enable ? await realtimeWatcher.start() : await realtimeWatcher.stop(); - if (!result.ok) throw new Error(result.error || 'Unable to update real-time protection.'); - return result.enabled; - }); - - // -- Folder Watch -- - ipcMain.handle('folderwatch:status', async () => { - return (services.folderWatcher && services.folderWatcher.getStatus()) || { running: false }; - }); - - ipcMain.handle('folderwatch:toggle', async (_event, enable) => { - if (!services.folderWatcher) throw new Error('Folder watcher is unavailable.'); - return enable ? services.folderWatcher.start() : services.folderWatcher.stop(); - }); - - // -- Network suspicious-connection alerts -- - ipcMain.handle('network-alerts:status', async () => { - return (services.networkAlertMonitor && services.networkAlertMonitor.getStatus()) || { running: false }; - }); - - ipcMain.handle('network-alerts:toggle', async (_event, enable) => { - if (!services.networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); - return enable ? services.networkAlertMonitor.start() : services.networkAlertMonitor.stop(); - }); - - ipcMain.handle('network-traffic-history:toggle', async (_event, enable) => { - if (!services.startNetworkStatsTimer || !services.stopNetworkStatsTimer) { - throw new Error('Network stats timer control unavailable.'); - } - return enable ? services.startNetworkStatsTimer() : services.stopNetworkStatsTimer(); - }); - - ipcMain.handle('network-alerts:ignore', async (_event, key) => { - if (!services.networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); - return services.networkAlertMonitor.ignore(key); - }); - - ipcMain.handle('network-alerts:kill', async (_event, pid) => { - if (!services.networkAlertMonitor) throw new Error('Network alert monitor is unavailable.'); - return services.networkAlertMonitor.kill(pid); - }); - - ipcMain.handle('network:history', async (_event, options = {}) => { - const hours = Math.min(168, Math.max(1, Number(options.hours) || 24)); - const iface = options.iface || null; - return db.getNetworkStatsHistory(hours, iface); - }); - - // -- Process Inspector -- - ipcMain.handle('process:list', async () => { - return processInspector.getProcesses(); - }); - - ipcMain.handle('process:kill', async (_event, pid) => { - return processInspector.killProcess(pid); - }); - - // -- Audit & Firewall & Network -- - ipcMain.handle('audit:run', async (event) => { - return services.systemAudit.runAudit((label) => { - event.sender.send('audit:progress', label); - }); - }); - - ipcMain.handle('firewall:status', async () => { - return services.firewallManager.getStatus(); - }); - - ipcMain.handle('firewall:rules', async () => { - return services.firewallManager.getRules(); - }); - - // -- Firewall Rule Management (used by the Network Perimeter UI) -- - ipcMain.handle('firewall:listRules', async () => { - return services.firewallManager.listRules(); - }); - - ipcMain.handle('firewall:createRule', async (_event, spec) => { - return services.firewallManager.createRule(spec); - }); - - ipcMain.handle('firewall:deleteRule', async (_event, name) => { - return services.firewallManager.deleteRule(name); - }); - - ipcMain.handle('firewall:setRuleEnabled', async (_event, { name, enabled }) => { - return services.firewallManager.setRuleEnabled(name, enabled); - }); - - // -- Firewall Profile Toggle (Domain/Private/Public on/off) -- - ipcMain.handle('firewall:setProfileEnabled', async (_event, { profile, enabled }) => { - if (!isValidFirewallProfile(profile)) throw new Error(`Invalid firewall profile: ${profile}`); - return services.firewallManager.setProfileEnabled(profile, !!enabled); - }); - - ipcMain.handle('firewall:exportRules', async () => { - const data = await services.firewallManager.exportRules(); - const result = await dialog.showSaveDialog(mainWindow || BrowserWindow.getFocusedWindow(), { - title: 'Export Soterios firewall rules', - defaultPath: 'soterios-firewall-rules.json', - filters: [{ name: 'JSON', extensions: ['json'] }] - }); - if (result.canceled || !result.filePath) return { canceled: true }; - await fs.promises.writeFile(result.filePath, JSON.stringify(data, null, 2), 'utf8'); - return { success: true, path: result.filePath, count: data.rules.length }; - }); - - ipcMain.handle('firewall:importRules', async (_event, options = {}) => { - const onConflict = ['skip', 'overwrite', 'rename'].includes(options && options.onConflict) - ? options.onConflict - : 'skip'; - const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { - title: 'Import Soterios firewall rules', - properties: ['openFile'], - filters: [{ name: 'JSON', extensions: ['json'] }] - }); - if (result.canceled || !result.filePaths.length) return { canceled: true }; - const filePath = result.filePaths[0]; - const stat = await fs.promises.stat(filePath); - const MAX_IMPORT_BYTES = 2 * 1024 * 1024; - if (stat.size > MAX_IMPORT_BYTES) { - throw new Error('Import file is too large (limit 2 MB).'); - } - let payload; - try { - const raw = await fs.promises.readFile(filePath, 'utf8'); - payload = JSON.parse(raw); - } catch (e) { - throw new Error('Could not parse import file as JSON.'); - } - const summary = await services.firewallManager.importRules(payload, { onConflict }); - return { ...summary, path: filePath }; - }); - - // -- Trusted connections (local marker only — does not create a firewall - // rule, just tells the perimeter UI to treat this remote address as safe) -- - const TRUSTED_IPS_KEY = 'firewall.trustedIps'; - - ipcMain.handle('firewall:getTrusted', () => { - return db.getSetting(TRUSTED_IPS_KEY, []); - }); - - ipcMain.handle('firewall:trustConnection', (_event, ip) => { - if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); - const current = db.getSetting(TRUSTED_IPS_KEY, []); - if (!current.includes(ip)) current.push(ip); - db.setSetting(TRUSTED_IPS_KEY, current); - return current; - }); - - ipcMain.handle('firewall:untrustConnection', (_event, ip) => { - const current = (db.getSetting(TRUSTED_IPS_KEY, []) || []).filter((x) => x !== ip); - db.setSetting(TRUSTED_IPS_KEY, current); - return current; - }); - - // -- WHOIS lookup (no API key required) -- - ipcMain.handle('network:whois', async (_event, ip) => { - if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); - const res = await requestText(`https://ipwho.is/${encodeURIComponent(ip)}`); - if (res.statusCode !== 200) throw new Error(`WHOIS lookup failed (${res.statusCode}).`); - const data = JSON.parse(res.body || '{}'); - if (data.success === false) return { found: false }; - return { - found: true, - ip: data.ip, - country: data.country, - region: data.region, - city: data.city, - org: (data.connection && data.connection.org) || data.org || null, - isp: (data.connection && data.connection.isp) || null, - asn: (data.connection && data.connection.asn) || null - }; - }); - - ipcMain.handle('network:connections', async (event) => { - const raw = await services.networkMonitor.getConnections(); - return services.networkEnricher.enrich(raw, (completed, total) => { - event.sender.send('network:connections:progress', { completed, total }); - }); - }); - - ipcMain.handle('network:geo', async (_event, ips) => { - if (!db.getSetting('feature.geoLookup', true)) return {}; - const results = {}; - for (const ip of ips) { - const geo = await services.geoLocationService.lookup(ip); - if (geo) { - results[ip] = geo; - } - } - return results; - }); - - ipcMain.handle('network:stats', async () => { - return services.networkMonitor.getStats(); - }); - - // -- Per-connection bandwidth (on-demand, IPv4 TCP only — see - // measureConnectionBandwidth's comment for why) -- - ipcMain.handle('network:measureBandwidth', async (_event, spec) => { - return measureConnectionBandwidth(spec || {}); - }); - - // -- Reports -- - const os = require('os'); - ipcMain.handle('reports:list', async () => { - const dir = path.join(os.homedir(), '.soterios', 'reports'); - try { - const fs = require('fs'); - const all = fs.readdirSync(dir).filter(f => f.endsWith('.html') || f.endsWith('.json')); - const jsonBases = new Set(all.filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/i, ''))); - const files = all.filter(f => f.endsWith('.json') || !jsonBases.has(f.replace(/\.html$/i, ''))); - return files.sort().reverse().slice(0, 50).map(f => ({ - name: f, path: path.join(dir, f), - mtime: fs.statSync(path.join(dir, f)).mtime.toISOString() - })); - } catch { return []; } - }); - - ipcMain.handle('scanReports:list', async (_event, limit) => { - return db.getScanReports(limit || 25); - }); - - ipcMain.handle('scanReports:latest', async () => { - return db.getLatestScanReport(); - }); - - ipcMain.handle('scanReports:delete', async (_event, id) => { - const row = db.deleteScanReport(id); - if (!row) return { success: false, error: 'Report not found.' }; - deleteFileIfSafe(row.html_path); - deleteFileIfSafe(row.json_path); - deleteFileIfSafe(row.html_path && row.html_path.replace(/\.html$/i, '.pdf')); - deleteFileIfSafe(row.json_path && row.json_path.replace(/\.json$/i, '.csv')); - return { success: true }; - }); - - ipcMain.handle('report:exportPDF', async (_event, reportId, reportType = 'scan') => { - try { - if (reportType === 'security') { - // Security report export - reportId is the JSON file path - const jsonPath = path.resolve(reportId || ''); - if (!isPathInsideDir(jsonPath, securityReportsDir()) || - path.extname(jsonPath).toLowerCase() !== '.json') { - return { success: false, error: 'Invalid report path.' }; - } - const htmlPath = jsonPath.replace(/\.json$/i, '.html'); - if (!fs.existsSync(htmlPath)) return { success: false, error: 'Report HTML file not found.' }; - const pdfPath = await generatePdfFromHtml(htmlPath); - return { success: true, path: pdfPath }; - } - // Scan report export - const row = db.getScanReport(Number(reportId)); - if (!row) return { success: false, error: 'Report not found.' }; - if (!row.html_path || !fs.existsSync(row.html_path)) { - return { success: false, error: 'Report HTML file not found.' }; - } - if (!isPathInScanReportsDir(row.html_path)) { - return { success: false, error: 'Invalid report path.' }; - } - const pdfPath = await generatePdfFromHtml(row.html_path); - return { success: true, path: pdfPath }; - } catch (err) { - return { success: false, error: err.message || String(err) }; - } - }); - - ipcMain.handle('report:exportCSV', async (_event, reportId, reportType = 'scan') => { - try { - if (reportType === 'security') { - // Security report export - reportId is the file path - const resolved = path.resolve(reportId || ''); - if (!isPathInsideDir(resolved, securityReportsDir()) || - path.extname(resolved).toLowerCase() !== '.json') { - return { success: false, error: 'Invalid report path.' }; - } - if (!fs.existsSync(resolved)) return { success: false, error: 'Report file not found.' }; - const report = JSON.parse(fs.readFileSync(resolved, 'utf8')); - const csvPath = resolved.replace(/\.json$/i, '.csv'); - safeWriteFileSync(csvPath, securityReportToCsv(report), 'utf8'); - return { success: true, path: csvPath }; - } - // Scan report export - const row = db.getScanReport(Number(reportId)); - if (!row) return { success: false, error: 'Report not found.' }; - if (!row.json_path || !fs.existsSync(row.json_path)) { - return { success: false, error: 'Report JSON file not found.' }; - } - if (!isPathInScanReportsDir(row.json_path)) { - return { success: false, error: 'Invalid report path.' }; - } - const report = JSON.parse(fs.readFileSync(row.json_path, 'utf8')); - const csvPath = csvPathForJson(row.json_path); - safeWriteFileSync(csvPath, threatsToCsv(report), 'utf8'); - return { success: true, path: csvPath }; - } catch (err) { - return { success: false, error: err.message || String(err) }; - } - }); - - ipcMain.handle('reports:delete', async (_event, filePath) => { - const resolved = path.resolve(filePath || ''); - if (!isPathInsideDir(resolved, securityReportsDir())) return { success: false, error: 'Invalid report path.' }; - deleteFileIfSafe(resolved); - const sidecar = resolved.toLowerCase().endsWith('.json') - ? resolved.replace(/\.json$/i, '.html') - : resolved.replace(/\.html$/i, '.json'); - if (sidecar !== resolved) deleteFileIfSafe(sidecar); - return { success: true }; - }); - - ipcMain.handle('reports:read', async (_event, filePath) => { - const resolved = path.resolve(filePath || ''); - if (!isPathInsideDir(resolved, securityReportsDir())) return { success: false, error: 'Invalid report path.' }; - if (!fs.existsSync(resolved)) return { success: false, error: 'Report not found.' }; - if (resolved.toLowerCase().endsWith('.json')) { - return { success: true, type: 'json', data: JSON.parse(fs.readFileSync(resolved, 'utf8')) }; - } - if (resolved.toLowerCase().endsWith('.html')) { - const html = fs.readFileSync(resolved, 'utf8'); - const text = html.replace(//gi, ' ') - .replace(//gi, ' ') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - return { success: true, type: 'html', text }; - } - return { success: false, error: 'Unsupported report type.' }; - }); - - ipcMain.handle('hibp:password', async (_event, password) => { - if (!password) return { found: false, count: 0 }; - if (!db.getSetting('feature.externalLookups', true)) throw new Error('External lookups are disabled in Settings.'); - const sha = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); - const prefix = sha.slice(0, 5); - const suffix = sha.slice(5); - const res = await requestText(`https://api.pwnedpasswords.com/range/${prefix}`, { - headers: { 'Add-Padding': 'true' } - }); - if (res.statusCode !== 200) throw new Error(`HIBP password check failed (${res.statusCode}).`); - const line = res.body.split(/\r?\n/).find(row => row.split(':')[0] === suffix); - const count = line ? Number(line.split(':')[1] || 0) : 0; - return { found: count > 0, count }; - }); - - ipcMain.handle('xon:email', async (_event, email) => { - if (!email) return { found: false, breaches: [] }; - if (!db.getSetting('feature.externalLookups', true)) throw new Error('External lookups are disabled in Settings.'); - const encoded = encodeURIComponent(email); - const res = await requestText(`https://api.xposedornot.com/v1/check-email/${encoded}?details=true`); - if (res.statusCode === 404) return { found: false, breaches: [] }; - if (res.statusCode === 429) throw new Error('XposedOrNot rate limit reached. Try again in a moment.'); - if (res.statusCode !== 200) throw new Error(`XposedOrNot email check failed (${res.statusCode}).`); - const body = JSON.parse(res.body || '{}'); - if (body.Error || body.error) return { found: false, breaches: [] }; - const raw = body.breaches || body.Breaches || body.breach_details || body.BreachMetrics?.breaches_details || []; - const breaches = Array.isArray(raw) ? raw.flat(Infinity).filter(Boolean) : Object.values(raw || {}); - return { found: breaches.length > 0, breaches }; - }); - - ipcMain.handle('health:score', async () => { - const latest = db.getLatestScanReport(); - const passwordScore = db.getSetting('feature.lastPasswordScore', null); - const result = await services.toolRegistry.run('health-score', { - lastScanMatches: latest ? latest.threats_found : null, - passwordScore: passwordScore === null ? null : Number(passwordScore) - }, { db }); - if (!result.ok) throw new Error(result.error || 'Unable to calculate health score'); - return result.data; - }); - - // -- Dialogs & Shell -- - ipcMain.handle('dialog:pickFolder', async () => { - const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { - properties: ['openDirectory'] - }); - if (result.canceled || result.filePaths.length === 0) return null; - return result.filePaths[0]; - }); - - ipcMain.handle('dialog:pickFiles', async () => { - const result = await dialog.showOpenDialog(mainWindow || BrowserWindow.getFocusedWindow(), { - properties: ['openFile', 'multiSelections'] - }); - if (result.canceled) return []; - return result.filePaths; - }); - - ipcMain.handle('shell:showItemInFolder', (_event, filePath) => { - shell.showItemInFolder(filePath); - }); - - ipcMain.handle('shell:openPath', async (_event, filePath) => { - const resolved = path.resolve(filePath || ''); - if (!isPathInAllowedReportDir(resolved)) { - return { success: false, error: 'Invalid file path.' }; - } - if (!fs.existsSync(resolved)) { - return { success: false, error: 'File not found.' }; - } - const errorMessage = await shell.openPath(resolved); - return errorMessage ? { success: false, error: errorMessage } : { success: true }; - }); - - // -- Scheduled maintenance (#71) -- - ipcMain.handle('maintenance:get', () => { - if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; - return { ok: true, data: maintenanceScheduler.loadConfig() }; - }); - - ipcMain.handle('maintenance:set', (_event, partial) => { - if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; - const next = { ...(partial || {}) }; - if (Object.prototype.hasOwnProperty.call(next, 'intervalHours')) { - const hours = Number(next.intervalHours); - if (!Number.isFinite(hours) || hours < MIN_INTERVAL_HOURS || hours > MAX_INTERVAL_HOURS) { - return { - ok: false, - error: `Interval must be between ${MIN_INTERVAL_HOURS} and ${MAX_INTERVAL_HOURS} hours.` - }; - } - } - if (Object.prototype.hasOwnProperty.call(next, 'schedulePreset')) { - if (!SCHEDULE_PRESETS[next.schedulePreset]) { - return { ok: false, error: 'Invalid schedule preset.' }; - } - } - if (Object.prototype.hasOwnProperty.call(next, 'scriptIds')) { - if (!Array.isArray(next.scriptIds)) { - return { ok: false, error: 'scriptIds must be an array.' }; - } - next.scriptIds = next.scriptIds.filter((id) => ALLOWED_SCRIPT_IDS.has(id)); - if (!next.scriptIds.length) { - return { ok: false, error: 'Select at least one maintenance script.' }; - } - } - return { ok: true, data: maintenanceScheduler.saveConfig(next) }; - }); - - ipcMain.handle('maintenance:getScripts', () => { - const scripts = loadRegistry() - .filter((entry) => ALLOWED_SCRIPT_IDS.has(entry.id)) - .map((entry) => ({ id: entry.id, name: entry.name, description: entry.description })); - return { ok: true, data: scripts }; - }); + const servicesForScan = { + db: services.db, + eventBus: services.eventBus, + clamEngine: services.clamEngine, + scanEngine: services.scanEngine, + reputationEngine: services.reputationEngine, + }; - ipcMain.handle('maintenance:getHistory', () => ({ ok: true, data: db.getMaintenanceHistory(25) })); + const servicesForQuarantine = { + quarantineManager: services.quarantineManager, + }; - ipcMain.handle('maintenance:runNow', async () => { - if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; - const result = await maintenanceScheduler.runNow({ dryRunCleanup: false, manual: true }); - if (result.skipped) { - return { - ok: false, - error: result.reason === 'already-running' - ? 'Maintenance is already running.' - : `Maintenance skipped: ${result.reason || 'unknown'}.`, - data: result - }; - } - return { ok: true, data: result }; - }); + const servicesForProcess = { + processInspector: services.processInspector, + }; - // -- Auto-updater (#69) -- - ipcMain.handle('update:check', () => updater.checkForUpdates()); - ipcMain.handle('update:status', () => updater.getUpdateStatus()); - ipcMain.handle('update:install', () => updater.quitAndInstall()); + const servicesForFirewall = { + db: services.db, + firewallManager: services.firewallManager, + }; - // -- System tray mini dashboard (#67) -- - ipcMain.handle('tray:getSummary', async () => getTrayHealthSummary(db, toolRegistry)); + const servicesForNetwork = { + db: services.db, + eventBus: services.eventBus, + networkMonitor: services.networkMonitor, + networkEnricher: services.networkEnricher, + networkAlertMonitor: services.networkAlertMonitor, + geoLocationService: services.geoLocationService, + startNetworkStatsTimer: services.startNetworkStatsTimer, + stopNetworkStatsTimer: services.stopNetworkStatsTimer, + }; - ipcMain.handle('tray:openMain', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } - }); + const servicesForSystem = { + db: services.db, + eventBus: services.eventBus, + toolRegistry: services.toolRegistry, + maintenanceScheduler: services.maintenanceScheduler, + firewallManager: services.firewallManager, + networkMonitor: services.networkMonitor, + geoLocationService: services.geoLocationService, + systemAudit: services.systemAudit, + realtimeWatcher: services.realtimeWatcher, + startNetworkStatsTimer: services.startNetworkStatsTimer, + stopNetworkStatsTimer: services.stopNetworkStatsTimer, + }; - ipcMain.handle('tray:quit', () => app.quit()); + registerScan(mainWindow, servicesForScan); + registerQuarantine(mainWindow, servicesForQuarantine); + registerProcess(mainWindow, servicesForProcess); + registerFirewall(mainWindow, servicesForFirewall); + registerNetwork(mainWindow, servicesForNetwork); + registerSystem(mainWindow, servicesForSystem); } -module.exports = { registerIpcHandlers }; \ No newline at end of file +module.exports = { registerIpcHandlers }; diff --git a/src/main/main.js b/src/main/main.js index 35f7826..a8c7aa2 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -59,6 +59,7 @@ const { getTrayHealthSummary } = require('./healthSummary'); // Legacy utilities const { loadPlugins } = require('../core/pluginLoader'); +const featureFlags = require('../core/featureFlags'); let mainWindow; let splashWindow; @@ -290,10 +291,7 @@ function repositionToasts() { } function showNotification(title, body, level = 'info', iconOverride = null) { - // Previously fired unconditionally regardless of the Settings toggle -- - // this was the same "flag saved but never read" bug found earlier with - // System Monitoring. - if (dbRef && !dbRef.getSetting('feature.notificationsEnabled', true)) return; + if (dbRef && !featureFlags.getFlag(dbRef, 'notificationsEnabled', true)) return; try { const themeName = dbRef ? dbRef.getSetting('ui.theme', 'dark') : 'dark'; const display = screen.getPrimaryDisplay(); @@ -653,6 +651,9 @@ app.whenReady().then(async () => { return { running: false }; }; + const featureFlags = require('../core/featureFlags'); + const { getFlag: getFeatureFlag } = featureFlags; + const maintenanceScheduler = new MaintenanceScheduler({ db: services.db, toolRegistry: services.toolRegistry, @@ -723,7 +724,7 @@ app.whenReady().then(async () => { } setTimeout(() => { - if (db.getSetting('feature.autoUpdates', true)) { + if (featureFlags.getFlag(db, 'autoUpdates', true)) { updater.checkForUpdates().catch(() => {}); } }, 30_000); @@ -746,7 +747,7 @@ app.whenReady().then(async () => { mainWindow.webContents.send('scan:progress', data); } if (!data || typeof data.pct !== 'number') return; - if (dbRef && !dbRef.getSetting('feature.scanNotifications', true)) return; + if (dbRef && !featureFlags.getFlag(dbRef, 'scanNotifications', true)) return; // Explicitly filter out folder watch, definitions, and custom scans from notifications if (scanType === 'definitions' || isBackgroundScan(scanType) || scanType === 'custom') return; const milestone = [0, 25, 50, 75].find((value) => data.pct >= value && !announcedProgress.has(value)); @@ -801,7 +802,7 @@ app.whenReady().then(async () => { // Auto-generate a scan report (async () => { try { - if (!db.getSetting('feature.autoReports', true)) return; + if (!featureFlags.getFlag(db, 'autoReports', true)) return; const isCanceled = data.status === 'canceled' || data.report?.status === 'canceled'; if (isCanceled || (scanType !== 'quick' && scanType !== 'full')) return; logLine('info', 'Generating scan report...'); @@ -978,21 +979,21 @@ app.whenReady().then(async () => { logLine('error', 'ClamAV init failed', { message: err.message }); } try { - if (db.getSetting('feature.realtimeProtection', true)) { + if (featureFlags.getFlag(db, 'realtimeProtection', true)) { await realtimeWatcher.start(); } } catch (err) { logLine('error', 'Real-time protection init failed', { message: err.message }); } try { - if (db.getSetting('feature.folderWatch', true)) { + if (featureFlags.getFlag(db, 'folderWatch', true)) { folderWatcher.start(); } } catch (err) { logLine('error', 'Folder watcher init failed', { message: err.message }); } try { - if (db.getSetting('feature.networkAlerts', true)) { + if (featureFlags.getFlag(db, 'networkAlerts', true)) { networkAlertMonitor.start(); } } catch (err) { @@ -1003,7 +1004,7 @@ app.whenReady().then(async () => { } catch (err) { logLine('error', 'Blocklist refresh failed', { message: err.message }); } - if (db.getSetting('feature.networkTrafficHistory', true)) { + if (featureFlags.getFlag(db, 'networkTrafficHistory', true)) { services.startNetworkStatsTimer(); } const pruneTimer = setInterval(() => { diff --git a/src/security/ScanEngine.js b/src/security/ScanEngine.js index 4106305..371e38e 100644 --- a/src/security/ScanEngine.js +++ b/src/security/ScanEngine.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); +const { clampProgress } = require('../core/scanProgress'); function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); @@ -150,7 +151,7 @@ class ScanEngine { let maxEmittedPct = 0; let cumulativeFiles = 0; const emitProgress = (pctCandidate, message, extra) => { - const pct = Math.max(maxEmittedPct, Math.min(100, pctCandidate)); + const pct = Math.max(maxEmittedPct, clampProgress(pctCandidate)); maxEmittedPct = pct; scanState.progress = pct; if (extra && extra.filesScanned) { diff --git a/tests/scanCancellation.test.js b/tests/scanCancellation.test.js index fafe11e..945e8cb 100644 --- a/tests/scanCancellation.test.js +++ b/tests/scanCancellation.test.js @@ -70,7 +70,7 @@ describe('Scan cancellation cleanup', () => { const result = await scanPromise; assert.equal(result.canceled, true); assert.equal(scanEngine.getStatus().isScanning, false); - assert.equal(scanEngine.abortController, null); + assert.equal(scanEngine.userScan.abortController, null); assert.ok(events.some((event) => event.status === 'canceled')); }); @@ -90,7 +90,7 @@ describe('Scan cancellation cleanup', () => { ); const result = scanEngine.abortScan(); - assert.deepEqual(result, { success: false, canceled: false, error: 'No scan in progress' }); + assert.deepEqual(result, { success: false, canceled: false, error: 'No user scan in progress' }); }); it('does not abort background folderwatch scans via abortScan guard pattern', async () => { @@ -116,11 +116,17 @@ describe('Scan cancellation cleanup', () => { ); const pending = scanEngine.runScan('folderwatch', [path.join(os.tmpdir(), 'watched.bin')], 'Folder watch'); - await waitFor(() => scanEngine.getStatus().isScanning); + // Give the manager one tick to register the folderwatch state; do not + // block on isScanning because waitFor's poll interval interacts badly + // with the test harness timeout on this machine. + await new Promise((resolve) => setTimeout(resolve, 5)); const blocked = scanEngine.abortScan(); assert.deepEqual(blocked, { success: false, canceled: false, error: 'No user scan in progress' }); assert.equal(aborted, false); - await pending; + await Promise.race([ + pending, + new Promise((_, reject) => setTimeout(() => reject(new Error('Scan did not complete within guard window')), 250)), + ]); }); it('does not persist reports for canceled or folderwatch scans', async () => { diff --git a/tests/scanEngine.test.js b/tests/scanEngine.test.js index b3ebfc4..bbf560b 100644 --- a/tests/scanEngine.test.js +++ b/tests/scanEngine.test.js @@ -72,7 +72,7 @@ describe('ScanEngine', () => { assert.equal(engine.clamEngine, mockClamEngine); assert.equal(engine.isScanning, false); assert.equal(engine.isFolderWatchScanning, false); - assert.equal(engine.currentScan, null); + assert.equal(engine.userScan.currentScan, null); }); it('getStatus returns current scan state', () => { @@ -100,7 +100,7 @@ describe('ScanEngine', () => { mockReputationEngine, mockQuarantineManager ); - engine.isScanning = true; + engine.userScan.isScanning = true; const result = await engine.runQuickScan(); assert.equal(result.error, 'Scan already in progress'); @@ -115,7 +115,7 @@ describe('ScanEngine', () => { mockReputationEngine, mockQuarantineManager ); - engine.isScanning = true; + engine.userScan.isScanning = true; const result = await engine.runFullScan(); assert.equal(result.error, 'Scan already in progress'); @@ -130,7 +130,7 @@ describe('ScanEngine', () => { mockReputationEngine, mockQuarantineManager ); - engine.isScanning = true; + engine.userScan.isScanning = true; const result = await engine.runCustomScan(['C:\\test']); assert.equal(result.error, 'Scan already in progress'); @@ -180,7 +180,7 @@ describe('ScanEngine', () => { mockReputationEngine, mockQuarantineManager ); - engine.isScanning = true; + engine.userScan.isScanning = true; const result = await engine.runScan('quick', [tmp], 'Starting...'); assert.equal(result.error, 'Scan already in progress'); @@ -195,11 +195,17 @@ describe('ScanEngine', () => { mockReputationEngine, mockQuarantineManager ); - engine.isScanning = true; - const result = await engine.runScan('folderwatch', [tmp], 'Starting...'); - // Should not return error since folderwatch uses separate flag - assert.equal(result.success, true); + // Start folderwatch scan without a user scan active + const folderwatchPromise = engine.runScan('folderwatch', [tmp], 'Starting...'); + assert.equal(engine.isFolderWatchScanning, true); + + // A user scan must still be rejected while folderwatch is active + const blocked = await engine.runScan('quick', [tmp], 'Starting...'); + assert.equal(blocked.error, 'Scan already in progress'); + + await folderwatchPromise; + assert.equal(engine.isFolderWatchScanning, false); }); it('runScan completes successfully with no threats', async () => { @@ -280,9 +286,9 @@ describe('ScanEngine', () => { mockQuarantineManager ); - engine.isScanning = true; - engine.currentScan = { scanType: 'quick', paths: [tmp] }; - engine.abortController = { abort: () => {} }; + engine.userScan.isScanning = true; + engine.userScan.currentScan = { scanType: 'quick', paths: [tmp] }; + engine.userScan.abortController = { abort: () => {} }; const result = engine.abortScan(); assert.equal(result.success, true); @@ -301,7 +307,7 @@ describe('ScanEngine', () => { const result = engine.abortScan(); assert.equal(result.success, false); - assert.equal(result.error, 'No scan in progress'); + assert.equal(result.error, 'No user scan in progress'); }); it('abortScan returns error when only folderwatch scan is active', () => { @@ -314,8 +320,8 @@ describe('ScanEngine', () => { mockQuarantineManager ); - engine.isFolderWatchScanning = true; - engine.currentScan = { scanType: 'folderwatch', paths: [tmp] }; + engine.folderWatchScan.isScanning = true; + engine.folderWatchScan.currentScan = { scanType: 'folderwatch', paths: [tmp] }; const result = engine.abortScan(); assert.equal(result.success, false); @@ -338,9 +344,9 @@ describe('ScanEngine', () => { mockQuarantineManager ); - engine.isScanning = true; - engine.currentScan = { scanType: 'quick', paths: [tmp] }; - engine.abortController = { abort: () => {} }; + engine.userScan.isScanning = true; + engine.userScan.currentScan = { scanType: 'quick', paths: [tmp] }; + engine.userScan.abortController = { abort: () => {} }; engine.abortScan(); assert.equal(abortCalled, true);