From 77d5686063aef68f28b4785ebd6cc79a4c58d7ba Mon Sep 17 00:00:00 2001 From: schvarts1 Date: Sun, 2 Aug 2026 02:44:00 +0200 Subject: [PATCH 1/2] fix: complete local-first improvement plan and restore app startup / tool IPC Split main.js into lifecycle.js and windowManager.js; harden toast BrowserWindow with contextIsolation/sandbox Add validateArgs IPC validation, structured error classes, and ALLOWED_INVOKE/ALLOWED_ON preload allowlists Wire IPC validation across 18+ handlers in scan, firewall, network, quarantine, process, system Register missing tools:list and tools:run handlers so tool actions work again Add auditLog module and wire into FirewallManager, EmergencyLockdown, QuarantineManager, ProcessInspector, maintenanceScheduler Extend database schema with user_blocklist, user_domain_blocklist, audit_log, scanned_files Add multi-folder scans, incremental scan support, and empty-catch cleanup Extract toast and scan-report templates; replace empty catch blocks with logging Add cross-platform ClamAV engine stubs and platform-aware serviceRegistry Preserve 282-test green baseline; verify with npm test --- browser-extension/native-host-manifest.json | 4 +- browser-extension/package.json | 3 +- package.json | 6 + src/core/auditLog.js | 31 + src/core/database.js | 164 +++ src/core/eventBus.js | 8 + src/core/workerManager.js | 7 +- src/main/healthSummary.js | 14 +- src/main/ipc/_shared.js | 16 +- src/main/ipc/firewall.js | 20 +- src/main/ipc/network.js | 60 +- src/main/ipc/process.js | 4 + src/main/ipc/quarantine.js | 7 + src/main/ipc/scan.js | 32 +- src/main/ipc/system.js | 91 +- src/main/ipc/validate.js | 110 ++ src/main/ipcHandlers.js | 1 + src/main/lifecycle.js | 511 ++++++++ src/main/main.js | 1053 ++--------------- src/main/maintenanceScheduler.js | 11 + src/main/serviceRegistry.js | 13 +- src/main/trayDashboard.js | 5 +- src/main/updater.js | 4 +- src/main/windowManager.js | 401 +++++++ src/preload/preload.js | 109 +- src/preload/toastPreload.js | 5 + src/scripts/childRunner.js | 2 +- src/scripts/safeScripts/browserCacheReport.js | 2 +- src/scripts/safeScripts/largeFilesReport.js | 2 +- src/scripts/safeScripts/listStartupItems.js | 4 +- .../safeScripts/uninstallLaunchUtils.js | 2 +- src/security/BlocklistService.js | 19 +- src/security/ClamAVEngine.js | 326 +---- src/security/ClamAVEngine.linux.js | 25 + src/security/ClamAVEngine.macos.js | 25 + src/security/ClamAVEngineBase.js | 336 ++++++ src/security/EmergencyLockdown.js | 32 +- src/security/FirewallManager.js | 65 +- src/security/FolderWatcher.js | 9 +- src/security/GeoLocationService.js | 27 +- src/security/NetworkMonitor.js | 45 +- src/security/ProcessInspector.js | 4 + src/security/QuarantineManager.js | 69 +- src/security/ScanEngine.js | 71 +- src/security/reportExport.js | 3 +- src/security/scripts/network-connections.ps1 | 8 + src/security/scripts/network-stats.ps1 | 34 + src/ui/js/api.js | 12 +- src/ui/js/pages/network.js | 2 +- src/ui/js/pages/settings.js | 2 +- src/ui/js/router.js | 2 +- src/ui/templates/scan-report.html | 28 + src/ui/templates/toast.html | 75 ++ src/utils/errors.js | 79 ++ src/utils/templates.js | 18 + tests/baseline-coverage.txt | 46 + tools/validate-native-host.js | 39 + 57 files changed, 2634 insertions(+), 1469 deletions(-) create mode 100644 src/core/auditLog.js create mode 100644 src/main/ipc/validate.js create mode 100644 src/main/lifecycle.js create mode 100644 src/main/windowManager.js create mode 100644 src/preload/toastPreload.js create mode 100644 src/security/ClamAVEngine.linux.js create mode 100644 src/security/ClamAVEngine.macos.js create mode 100644 src/security/ClamAVEngineBase.js create mode 100644 src/security/scripts/network-connections.ps1 create mode 100644 src/security/scripts/network-stats.ps1 create mode 100644 src/ui/templates/scan-report.html create mode 100644 src/ui/templates/toast.html create mode 100644 src/utils/errors.js create mode 100644 src/utils/templates.js create mode 100644 tests/baseline-coverage.txt create mode 100644 tools/validate-native-host.js diff --git a/browser-extension/native-host-manifest.json b/browser-extension/native-host-manifest.json index 2bb5fc2..94b9c1f 100644 --- a/browser-extension/native-host-manifest.json +++ b/browser-extension/native-host-manifest.json @@ -1,9 +1,9 @@ { "name": "com.soterios.credential_safety", "description": "Soterios Credential Safety Native Messaging Host", - "path": "src/native-host.bat", + "path": "native-host.bat", "type": "stdio", "allowed_origins": [ - "chrome-extension:///" + "chrome-extension://__EXTENSION_ID_PLACEHOLDER__/" ] } \ No newline at end of file diff --git a/browser-extension/package.json b/browser-extension/package.json index 5466349..c5bed9e 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -6,7 +6,8 @@ "scripts": { "build:icons": "node tools/build-icons.js", "package": "npm run build:icons && cd browser-extension && zip -r ../soterios-extension.zip . -x '*.DS_Store' 'icons/*.svg' 'tools/*'", - "install:host": "node tools/install-native-host.js" + "install:host": "node tools/install-native-host.js", + "postinstall": "node ../tools/validate-native-host.js" }, "devDependencies": { "svgexport": "^0.4.2" diff --git a/package.json b/package.json index e7b853b..5593bd3 100644 --- a/package.json +++ b/package.json @@ -114,5 +114,11 @@ "repo": "Soterios" } ] + }, + "allowScripts": { + "better-sqlite3@13.0.2": true, + "canvas@3.2.3": true, + "electron-winstaller@5.4.0": true, + "unrs-resolver@1.12.2": true } } diff --git a/src/core/auditLog.js b/src/core/auditLog.js new file mode 100644 index 0000000..4eddfc3 --- /dev/null +++ b/src/core/auditLog.js @@ -0,0 +1,31 @@ +'use strict'; + +const ACTIONS = Object.freeze({ + FIREWALL_RULE_CREATE: 'firewall.rule.create', + FIREWALL_RULE_DELETE: 'firewall.rule.delete', + FIREWALL_RULE_TOGGLE: 'firewall.rule.toggle', + LOCKDOWN_ACTIVATE: 'lockdown.activate', + LOCKDOWN_RESTORE: 'lockdown.restore', + QUARANTINE_ADD: 'quarantine.add', + QUARANTINE_RESTORE: 'quarantine.restore', + QUARANTINE_DELETE: 'quarantine.delete', + PROCESS_KILL: 'process.kill', + SETTING_CHANGE: 'setting.change', + MAINTENANCE_RUN: 'maintenance.run', +}); + +function log(db, action, detail, result, userInitiated = false) { + if (!db || !action) return; + try { + db.addAuditEntry({ + action, + detail: JSON.stringify(detail), + result: JSON.stringify(result), + userInitiated: userInitiated ? 1 : 0, + }); + } catch (_) { + // Audit logging must never break the primary action. + } +} + +module.exports = { ACTIONS, log }; diff --git a/src/core/database.js b/src/core/database.js index de700c9..c68e9d8 100644 --- a/src/core/database.js +++ b/src/core/database.js @@ -160,6 +160,51 @@ class DatabaseService { CREATE INDEX IF NOT EXISTS idx_network_stats_recorded_at ON network_stats(recorded_at) `); + + // User blocklist + this.db.exec(` + CREATE TABLE IF NOT EXISTS user_blocklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + reason TEXT, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_blocklist_ip ON user_blocklist(ip) + `); + + // User domain blocklist + this.db.exec(` + CREATE TABLE IF NOT EXISTS user_domain_blocklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + reason TEXT, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Audit log + this.db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + action TEXT NOT NULL, + detail TEXT, + result TEXT, + user_initiated INTEGER DEFAULT 0 + ) + `); + + // Scanned files cache for incremental scans + this.db.exec(` + CREATE TABLE IF NOT EXISTS scanned_files ( + path TEXT PRIMARY KEY, + last_scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + size INTEGER, + modified_at TEXT + ) + `); } // --- Scan History API --- @@ -452,6 +497,125 @@ class DatabaseService { const cutoff = new Date(Date.now() - Number(retentionDays) * 86400 * 1000).toISOString(); return this.db.prepare('DELETE FROM network_stats WHERE recorded_at < ?').run(cutoff); } + + // --- Alerts API --- + getAlerts(options = {}) { + const limit = Math.min(500, Number(options.limit) || 50); + const unreadOnly = !!options.unreadOnly; + let sql = 'SELECT * FROM alerts'; + if (unreadOnly) sql += ' WHERE is_read = 0'; + sql += ' ORDER BY timestamp DESC LIMIT ?'; + return this.db.prepare(sql).all(limit); + } + + getAlertCounts() { + const total = this.db.prepare('SELECT COUNT(*) AS c FROM alerts').get().c; + const unread = this.db.prepare('SELECT COUNT(*) AS c FROM alerts WHERE is_read = 0').get().c; + return { total, unread }; + } + + // --- Audit Log API --- + addAuditEntry({ action, detail, result, userInitiated = false }) { + const stmt = this.db.prepare(` + INSERT INTO audit_log (action, detail, result, user_initiated) + VALUES (?, ?, ?, ?) + `); + return stmt.run(action, detail, result, userInitiated ? 1 : 0); + } + + getAuditLog(limit = 100) { + return this.db.prepare('SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?').all(limit); + } + + // --- User Blocklist API --- + addUserBlocklistEntry(entry) { + const stmt = this.db.prepare(` + INSERT INTO user_blocklist (ip, reason) VALUES (@ip, @reason) + `); + return stmt.run({ ip: entry.ip, reason: entry.reason || null }); + } + + removeUserBlocklistEntry(id) { + return this.db.prepare('DELETE FROM user_blocklist WHERE id = ?').run(id); + } + + getUserBlocklist() { + return this.db.prepare('SELECT * FROM user_blocklist ORDER BY added_at DESC').all(); + } + + clearUserBlocklist() { + return this.db.prepare('DELETE FROM user_blocklist').run(); + } + + // --- User Domain Blocklist API --- + addUserDomainBlocklistEntry(entry) { + const stmt = this.db.prepare(` + INSERT INTO user_domain_blocklist (domain, reason) VALUES (@domain, @reason) + `); + return stmt.run({ domain: entry.domain, reason: entry.reason || null }); + } + + removeUserDomainBlocklistEntry(id) { + return this.db.prepare('DELETE FROM user_domain_blocklist WHERE id = ?').run(id); + } + + getUserDomainBlocklist() { + return this.db.prepare('SELECT * FROM user_domain_blocklist ORDER BY added_at DESC').all(); + } + + clearUserDomainBlocklist() { + return this.db.prepare('DELETE FROM user_domain_blocklist').run(); + } + + // --- Settings Export --- + exportAllSettings() { + const settings = {}; + const rows = this.db.prepare('SELECT key, value FROM settings').all(); + for (const row of rows) { + try { settings[row.key] = JSON.parse(row.value); } + catch (_) { settings[row.key] = row.value; } + } + return settings; + } + + exportQuarantineState() { + return this.db.prepare(` + SELECT id, original_path, quarantine_path, hash, engine, + threat_name, date_quarantined, reason, status + FROM quarantine WHERE status = 'quarantined' + `).all(); + } + + // --- Incremental Scan Cache --- + recordScannedFile({ path, size, modifiedAt }) { + const stmt = this.db.prepare(` + INSERT INTO scanned_files (path, size, modified_at) + VALUES (@path, @size, @modifiedAt) + ON CONFLICT(path) DO UPDATE SET + size = excluded.size, + modified_at = excluded.modified_at, + last_scanned_at = CURRENT_TIMESTAMP + `); + return stmt.run({ path, size: size || null, modifiedAt: modifiedAt || null }); + } + + getFilesToSkip(paths) { + if (!paths || !paths.length) return new Set(); + const placeholders = paths.map(() => '?').join(','); + const rows = this.db.prepare(` + SELECT path, modified_at FROM scanned_files WHERE path IN (${placeholders}) + `).all(...paths); + const skip = new Set(); + for (const row of rows) { + skip.add(row.path); + } + return skip; + } + + pruneScannedFiles(olderThanDays = 30) { + const cutoff = new Date(Date.now() - Number(olderThanDays) * 86400 * 1000).toISOString(); + return this.db.prepare('DELETE FROM scanned_files WHERE last_scanned_at < ?').run(cutoff); + } } module.exports = DatabaseService; diff --git a/src/core/eventBus.js b/src/core/eventBus.js index b735f5e..a5d0051 100644 --- a/src/core/eventBus.js +++ b/src/core/eventBus.js @@ -5,6 +5,14 @@ class EventBus { this._listeners.get(eventName).add(handler); return () => this.off(eventName, handler); } + once(eventName, handler) { + const wrapper = (payload) => { + this.off(eventName, wrapper); + return handler(payload); + }; + this.on(eventName, wrapper); + return () => this.off(eventName, wrapper); + } off(eventName, handler) { if (!this._listeners.has(eventName)) return; this._listeners.get(eventName).delete(handler); diff --git a/src/core/workerManager.js b/src/core/workerManager.js index d85a473..b7b731f 100644 --- a/src/core/workerManager.js +++ b/src/core/workerManager.js @@ -2,6 +2,7 @@ const { Worker } = require('worker_threads'); const path = require('path'); +const logger = require('../utils/logger'); const WORKER_ENTRY = path.join(__dirname, '../scripts/workerEntry.js'); const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; @@ -30,13 +31,13 @@ class WorkerManager { }; const timer = setTimeout(() => { - try { worker.terminate(); } catch (_) {} + try { worker.terminate(); } catch (e) { logger.debug('worker terminate on timeout failed', { error: e?.message || String(e) }); } finish(reject, new Error('Script timed out')); }, timeoutMs); if (typeof timer.unref === 'function') timer.unref(); const onAbort = () => { - try { worker.terminate(); } catch (_) {} + try { worker.terminate(); } catch (e) { logger.debug('worker terminate on abort failed', { error: e?.message || String(e) }); } finish(reject, new Error('Task canceled')); }; @@ -79,7 +80,7 @@ class WorkerManager { cancel(taskId) { const task = this._tasks.get(taskId); if (!task) return false; - try { task.worker.terminate(); } catch (_) {} + try { task.worker.terminate(); } catch (e) { logger.debug('worker terminate on cancel failed', { error: e?.message || String(e) }); } this._tasks.delete(taskId); return true; } diff --git a/src/main/healthSummary.js b/src/main/healthSummary.js index 692fc5a..416a2b9 100644 --- a/src/main/healthSummary.js +++ b/src/main/healthSummary.js @@ -1,5 +1,7 @@ 'use strict'; +const logger = require('../utils/logger'); + /** * Shared health summary for tray popup and IPC handlers. * @param {import('../core/database')} db @@ -27,7 +29,9 @@ async function getTrayHealthSummary(db, toolRegistry) { // Check if RTP is enabled in settings const rtpEnabled = db.getSetting('feature.realtimeProtection', false); rtp = { enabled: rtpEnabled }; - } catch (_) {} + } catch (e) { + logger.debug('RTP status check failed', { error: e?.message || String(e) }); + } // Firewall status let firewall = { active: false }; @@ -37,7 +41,9 @@ async function getTrayHealthSummary(db, toolRegistry) { const execFileAsync = promisify(execFile); const { stdout } = await execFileAsync('netsh', ['advfirewall', 'show', 'allprofiles', 'state'], { timeout: 5000 }); firewall = { active: /ON|ENABLED/i.test(stdout) }; - } catch (_) {} + } catch (e) { + logger.debug('Firewall status check failed', { error: e?.message || String(e) }); + } // Network traffic history (last 24h) let network = { rxKBs: 0, txKBs: 0, history: [], rx: [], tx: [] }; @@ -53,7 +59,9 @@ async function getTrayHealthSummary(db, toolRegistry) { network.tx = recent.map(h => (h.tx_bytes || 0) / 1024); network.history = recent.map(h => (h.tx_bytes + h.rx_bytes) / 1024); } - } catch (_) {} + } catch (e) { + logger.debug('Network history lookup failed', { error: e?.message || String(e) }); + } // Last scan info let lastScan = null; diff --git a/src/main/ipc/_shared.js b/src/main/ipc/_shared.js index 12982b6..ce14206 100644 --- a/src/main/ipc/_shared.js +++ b/src/main/ipc/_shared.js @@ -1,5 +1,7 @@ const https = require('https'); +const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MB + function requestText(url, options = {}) { return new Promise((resolve, reject) => { const req = https.request(url, { @@ -11,8 +13,18 @@ function requestText(url, options = {}) { }, (res) => { let body = ''; res.setEncoding('utf8'); - res.on('data', chunk => { body += chunk; }); - res.on('end', () => resolve({ statusCode: res.statusCode, body })); + res.on('data', chunk => { + body += chunk; + if (Buffer.byteLength(body) > MAX_API_BODY_BYTES) { + req.destroy(new Error('Response body exceeds size limit')); + reject(new Error('Response too large')); + } + }); + res.on('end', () => { + if (!req.destroyed) { + resolve({ statusCode: res.statusCode, body }); + } + }); }); req.on('error', reject); req.setTimeout(15000, () => req.destroy(new Error('Request timed out'))); diff --git a/src/main/ipc/firewall.js b/src/main/ipc/firewall.js index 7f6fc97..c2b3ccc 100644 --- a/src/main/ipc/firewall.js +++ b/src/main/ipc/firewall.js @@ -2,6 +2,8 @@ const { ipcMain, dialog, BrowserWindow } = require('electron'); const path = require('path'); const fs = require('fs'); const { requestText } = require('../ipc/_shared'); +const { validateArgs } = require('./validate'); +const { InvalidInputError, AppError } = require('../../utils/errors'); const { isPathInScanReportsDir, } = require('../../security/reportExport'); @@ -32,10 +34,16 @@ function register(mainWindow, { db, firewallManager }) { }); ipcMain.handle('firewall:createRule', async (_event, spec) => { + validateArgs([ + { name: 'spec', type: 'object', required: true }, + ], [spec]); return firewallManager.createRule(spec); }); ipcMain.handle('firewall:deleteRule', async (_event, name) => { + validateArgs([ + { name: 'name', type: 'string', required: true, max: 256 }, + ], [name]); return firewallManager.deleteRule(name); }); @@ -44,7 +52,7 @@ function register(mainWindow, { db, firewallManager }) { }); ipcMain.handle('firewall:setProfileEnabled', async (_event, { profile, enabled }) => { - if (!isValidFirewallProfile(profile)) throw new Error(`Invalid firewall profile: ${profile}`); + if (!isValidFirewallProfile(profile)) throw new InvalidInputError(`Invalid firewall profile: ${profile}`); return firewallManager.setProfileEnabled(profile, !!enabled); }); @@ -74,14 +82,14 @@ function register(mainWindow, { db, firewallManager }) { 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).'); + throw new InvalidInputError('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.'); + throw new InvalidInputError('Could not parse import file as JSON.'); } const summary = await firewallManager.importRules(payload, { onConflict }); return { ...summary, path: filePath }; @@ -94,7 +102,7 @@ function register(mainWindow, { db, firewallManager }) { }); ipcMain.handle('firewall:trustConnection', (_event, ip) => { - if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); + if (!ip || !isValidIp(ip)) throw new InvalidInputError('Invalid address.'); const current = db.getSetting(TRUSTED_IPS_KEY, []); if (!current.includes(ip)) current.push(ip); db.setSetting(TRUSTED_IPS_KEY, current); @@ -109,9 +117,9 @@ function register(mainWindow, { db, firewallManager }) { // -- WHOIS lookup (no API key required) -- ipcMain.handle('network:whois', async (_event, ip) => { - if (!ip || !isValidIp(ip)) throw new Error('Invalid address.'); + if (!ip || !isValidIp(ip)) throw new InvalidInputError('Invalid address.'); const res = await requestText(`https://ipwho.is/${encodeURIComponent(ip)}`); - if (res.statusCode !== 200) throw new Error(`WHOIS lookup failed (${res.statusCode}).`); + if (res.statusCode !== 200) throw new AppError(`WHOIS lookup failed (${res.statusCode}).`); const data = JSON.parse(res.body || '{}'); if (data.success === false) return { found: false }; return { diff --git a/src/main/ipc/network.js b/src/main/ipc/network.js index af65b96..c8dbfcf 100644 --- a/src/main/ipc/network.js +++ b/src/main/ipc/network.js @@ -3,6 +3,8 @@ const { execFile } = require('child_process'); const util = require('util'); const execFilePromise = util.promisify(execFile); const logger = require('../../utils/logger'); +const { validateArgs } = require('./validate'); +const { InvalidInputError } = require('../../utils/errors'); const featureFlags = require('../../core/featureFlags'); @@ -24,12 +26,12 @@ async function runPowerShellRaw(command) { async function measureConnectionBandwidth({ localAddress, localPort, remoteAddress, remotePort }) { if (!isValidIPv4(localAddress) || !isValidIPv4(remoteAddress)) { - throw new Error('Per-connection bandwidth currently only supports IPv4 TCP connections.'); + throw new InvalidInputError('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.'); + throw new InvalidInputError('Invalid port.'); } const script = ` @@ -196,8 +198,62 @@ function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, n // -- Per-connection bandwidth (on-demand, IPv4 TCP only -- see // measureConnectionBandwidth's comment for why) -- ipcMain.handle('network:measureBandwidth', async (_event, spec) => { + validateArgs([ + { name: 'localAddress', type: 'string', required: true }, + { name: 'localPort', type: 'number', required: true, min: 0, max: 65535 }, + { name: 'remoteAddress', type: 'string', required: true }, + { name: 'remotePort', type: 'number', required: true, min: 0, max: 65535 }, + ], [spec]); return measureConnectionBandwidth(spec || {}); }); + + // -- User IP blocklist -- + ipcMain.handle('network:userBlocklist:list', async () => { + return db.getUserBlocklist(); + }); + + ipcMain.handle('network:userBlocklist:add', async (_event, entry) => { + validateArgs([ + { name: 'ip', type: 'string', required: true }, + { name: 'reason', type: 'string', required: false }, + ], [entry]); + return db.addUserBlocklistEntry(entry); + }); + + ipcMain.handle('network:userBlocklist:remove', async (_event, id) => { + validateArgs([ + { name: 'id', type: 'number', required: true, min: 1 }, + ], [id]); + return db.removeUserBlocklistEntry(id); + }); + + ipcMain.handle('network:userBlocklist:clear', async () => { + return db.clearUserBlocklist(); + }); + + // -- Domain blocklist -- + ipcMain.handle('network:domainBlocklist:list', async () => { + return db.getUserDomainBlocklist(); + }); + + ipcMain.handle('network:domainBlocklist:add', async (_event, entry) => { + validateArgs([ + { name: 'domain', type: 'string', required: true }, + { name: 'reason', type: 'string', required: false }, + ], [entry]); + return db.addUserDomainBlocklistEntry(entry); + }); + + ipcMain.handle('network:domainBlocklist:remove', async (_event, id) => { + validateArgs([ + { name: 'id', type: 'number', required: true, min: 1 }, + ], [id]); + return db.removeUserDomainBlocklistEntry(id); + }); + + ipcMain.handle('network:domainBlocklist:clear', async () => { + return db.clearUserDomainBlocklist(); + }); } module.exports = { register }; diff --git a/src/main/ipc/process.js b/src/main/ipc/process.js index 37408ce..5604bb4 100644 --- a/src/main/ipc/process.js +++ b/src/main/ipc/process.js @@ -1,4 +1,5 @@ const { ipcMain } = require('electron'); +const { validateArgs } = require('./validate'); function register(mainWindow, { processInspector }) { ipcMain.handle('process:list', async () => { @@ -6,6 +7,9 @@ function register(mainWindow, { processInspector }) { }); ipcMain.handle('process:kill', async (_event, pid) => { + validateArgs([ + { name: 'pid', type: 'number', required: true, min: 1 }, + ], [pid]); return processInspector.killProcess(pid); }); } diff --git a/src/main/ipc/quarantine.js b/src/main/ipc/quarantine.js index b41a1db..139373a 100644 --- a/src/main/ipc/quarantine.js +++ b/src/main/ipc/quarantine.js @@ -1,11 +1,18 @@ const { ipcMain } = require('electron'); +const { validateArgs } = require('./validate'); function register(mainWindow, { quarantineManager }) { ipcMain.handle('quarantine:restore', async (_event, id) => { + validateArgs([ + { name: 'id', type: 'number', required: true, min: 1 }, + ], [id]); return quarantineManager.restore(id); }); ipcMain.handle('quarantine:delete', async (_event, id) => { + validateArgs([ + { name: 'id', type: 'number', required: true, min: 1 }, + ], [id]); return quarantineManager.delete(id); }); } diff --git a/src/main/ipc/scan.js b/src/main/ipc/scan.js index c7d588e..d434552 100644 --- a/src/main/ipc/scan.js +++ b/src/main/ipc/scan.js @@ -1,11 +1,13 @@ const { ipcMain } = require('electron'); const i18n = require('../../i18n'); const logger = require('../../utils/logger'); +const { validateArgs } = require('./validate'); +const fs = require('fs'); const DEFAULT_SCHEDULE = { enabled: false, scanType: 'quick', - customPath: null, + customPaths: [], intervalHours: 24, lastRun: null, }; @@ -56,6 +58,9 @@ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputation }); ipcMain.handle('scan:custom', async (_event, targetPaths) => { + validateArgs([ + { name: 'targetPaths', type: 'array', required: true, minItems: 1, maxItems: 50 } + ], [targetPaths]); return scanEngine.runCustomScan(targetPaths); }); @@ -69,6 +74,11 @@ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputation // -- Reputation -- ipcMain.handle('reputation:addHash', async (_event, hash, verdict, note) => { + validateArgs([ + { name: 'hash', type: 'string', required: true, pattern: /^[a-f0-9]{64}$/i }, + { name: 'verdict', type: 'string', required: true, allowed: ['safe', 'malicious'] }, + { name: 'note', type: 'string', required: false }, + ], [hash, verdict, note]); return reputationEngine.addHash(hash, verdict, note); }); @@ -101,9 +111,21 @@ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputation ipcMain.handle('schedule:get', () => loadScheduleConfig()); ipcMain.handle('schedule:set', (_event, config) => { + validateArgs([ + { name: 'config', type: 'object', required: false }, + { name: 'config.enabled', type: 'boolean', required: false }, + { name: 'config.scanType', type: 'string', required: false, allowed: ['quick', 'full', 'custom'] }, + { name: 'config.intervalHours', type: 'number', required: false, min: 1, max: 720 }, + { name: 'config.customPaths', type: 'array', required: false }, + ], [config]); return saveScheduleConfig(config || {}); }); + ipcMain.handle('schedule:getCustomPaths', () => { + const config = loadScheduleConfig(); + return config.customPaths || []; + }); + // 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; @@ -111,7 +133,7 @@ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputation if (scheduledScanRunning) return; const config = loadScheduleConfig(); if (!config.enabled) return; - if (config.scanType === 'custom' && !config.customPath) return; + if (config.scanType === 'custom' && (!config.customPaths || !config.customPaths.length)) return; const engineStatus = scanEngine.getStatus(); if (engineStatus && (engineStatus.isScanning || engineStatus.isFolderWatchScanning)) return; // don't collide with any scan @@ -126,7 +148,11 @@ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputation if (config.scanType === 'full') { await scanEngine.runFullScan(); } else if (config.scanType === 'custom') { - await scanEngine.runCustomScan([config.customPath]); + for (const customPath of config.customPaths) { + if (fs.existsSync(customPath)) { + await scanEngine.runCustomScan([customPath]); + } + } } else { await scanEngine.runQuickScan(); } diff --git a/src/main/ipc/system.js b/src/main/ipc/system.js index 00008b1..ece566d 100644 --- a/src/main/ipc/system.js +++ b/src/main/ipc/system.js @@ -3,6 +3,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const os = require('os'); +const { execSync } = require('child_process'); const { isPathInScanReportsDir, isPathInAllowedReportDir, @@ -19,14 +20,18 @@ 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 logger = require('../../utils/logger'); const { requestText } = require('./_shared'); const featureFlags = require('../../core/featureFlags'); +const { AppError, PermissionError } = require('../../utils/errors'); function deleteFileIfSafe(filePath) { if (!filePath) return; try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch (_) { } + } catch (e) { + logger.debug?.('deleteFileIfSafe failed', { filePath, error: e?.message || String(e) }); + } } function register(mainWindow, { @@ -43,13 +48,14 @@ function register(mainWindow, { startNetworkStatsTimer, stopNetworkStatsTimer, emergencyLockdown, + isActuallyAdmin = false, }) { // -- System -- ipcMain.handle('app:info', () => ({ name: app.getName(), version: app.getVersion(), userData: app.getPath('userData'), - isAdmin: true, // We requested admin rights + isAdmin: !!isActuallyAdmin, })); // -- Launch at Startup -- @@ -70,7 +76,7 @@ function register(mainWindow, { 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.'); + if (!result.ok) throw new AppError(result.error || 'Unable to update real-time protection.'); return result.enabled; }); @@ -134,6 +140,41 @@ function register(mainWindow, { }); }); + ipcMain.handle('audit:log', async (_event, entry) => { + validateArgs([ + { name: 'action', type: 'string', required: true }, + { name: 'detail', type: 'string', required: false }, + { name: 'result', type: 'string', required: false }, + { name: 'userInitiated', type: 'boolean', required: false }, + ], [entry]); + return db.addAuditEntry({ + action: entry.action, + detail: entry.detail || null, + result: entry.result || null, + userInitiated: !!entry.userInitiated, + }); + }); + + ipcMain.handle('alerts:list', async (_event, options = {}) => { + validateArgs([ + { name: 'limit', type: 'number', required: false, min: 1, max: 500 }, + { name: 'unreadOnly', type: 'boolean', required: false }, + ], [options]); + return db.getAlerts(options); + }); + + ipcMain.handle('alerts:counts', async () => { + return db.getAlertCounts(); + }); + + ipcMain.handle('app:exportSettings', async () => { + return { + settings: db.exportAllSettings(), + quarantine: db.exportQuarantineState(), + exportedAt: new Date().toISOString(), + }; + }); + // -- Scheduled maintenance (#71) -- ipcMain.handle('maintenance:get', () => { if (!maintenanceScheduler) return { ok: false, error: 'Maintenance scheduler unavailable.' }; @@ -338,14 +379,14 @@ function register(mainWindow, { // -- 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.'); + if (!featureFlags.getFlag(db, 'externalLookups', true)) throw new PermissionError('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}).`); + if (res.statusCode !== 200) throw new AppError(`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 }; @@ -353,12 +394,12 @@ function register(mainWindow, { 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.'); + if (!featureFlags.getFlag(db, 'externalLookups', true)) throw new PermissionError('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}).`); + if (res.statusCode === 429) throw new AppError('XposedOrNot rate limit reached. Try again in a moment.'); + if (res.statusCode !== 200) throw new AppError(`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 || []; @@ -373,7 +414,7 @@ function register(mainWindow, { 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'); + if (!result.ok) throw new AppError(result.error || 'Unable to calculate health score'); return result.data; }); @@ -418,7 +459,7 @@ function register(mainWindow, { execSync(regCmd, { stdio: 'ignore' }); const regPathEdge = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${manifest.name}`; const regCmdEdge = `reg add "${regPathEdge}" /ve /t REG_SZ /d "${manifestPath.replace(/\\/g, '\\\\')}" /f`; - try { execSync(regCmdEdge, { stdio: 'ignore' }); } catch (_) {} + try { execSync(regCmdEdge, { stdio: 'ignore' }); } catch (e) { logger.debug?.('Native host Edge reg write failed', { error: e?.message || String(e) }); } return { ok: true }; } catch (e) { return { ok: false, error: e.message || String(e) }; @@ -509,6 +550,9 @@ function register(mainWindow, { }); ipcMain.handle('lockdown:setAllowlist', async (event, allowlist) => { + validateArgs([ + { name: 'allowlist', type: 'array', required: true, minItems: 0, maxItems: 1000 } + ], [allowlist]); if (!emergencyLockdown) { return { ok: false, error: 'Emergency lockdown service unavailable' }; } @@ -521,6 +565,10 @@ function register(mainWindow, { }); ipcMain.handle('lockdown:addToAllowlist', async (event, type, value) => { + validateArgs([ + { name: 'type', type: 'string', required: true, allowed: ['ip', 'port', 'program', 'interface'] }, + { name: 'value', type: 'string', required: true }, + ], [type, value]); if (!emergencyLockdown) { return { ok: false, error: 'Emergency lockdown service unavailable' }; } @@ -533,6 +581,10 @@ function register(mainWindow, { }); ipcMain.handle('lockdown:removeFromAllowlist', async (event, type, value) => { + validateArgs([ + { name: 'type', type: 'string', required: true, allowed: ['ip', 'port', 'program', 'interface'] }, + { name: 'value', type: 'string', required: true }, + ], [type, value]); if (!emergencyLockdown) { return { ok: false, error: 'Emergency lockdown service unavailable' }; } @@ -543,6 +595,25 @@ function register(mainWindow, { return { ok: false, error: err.message }; } }); + + // -- Tools -- + ipcMain.handle('tools:list', async () => { + try { + const list = toolRegistry.list(); + return { ok: true, data: list }; + } catch (err) { + return { ok: false, error: err.message }; + } + }); + + ipcMain.handle('tools:run', async (_event, toolId, args = {}) => { + try { + const result = await toolRegistry.run(toolId, args, { db, eventBus, mainWindow }); + return result; + } catch (err) { + return { ok: false, error: err.message }; + } + }); } module.exports = { register }; diff --git a/src/main/ipc/validate.js b/src/main/ipc/validate.js new file mode 100644 index 0000000..922dded --- /dev/null +++ b/src/main/ipc/validate.js @@ -0,0 +1,110 @@ +'use strict'; + +const { InvalidInputError } = require('../../utils/errors'); + +/** + * Validate IPC arguments against a schema. + * + * Schema shape (array of rule objects): + * [ + * { name: 'targetPaths', type: 'array', required: true, minItems: 1 }, + * { name: 'limit', type: 'number', required: false, min: 1, max: 500 }, + * { name: 'channel', type: 'string', required: true, allowed: ['scan','custom'] }, + * { name: 'path', type: 'string', required: true, pattern: /^[A-Za-z]:\\/ } + * ] + * + * Supported type validators: 'string', 'number', 'boolean', 'array', 'object' + * Supported constraints: required, min, max, minItems, maxItems, allowed, pattern + * + * @param {{ name: string, type: string, required?: boolean, min?: number, max?: number, minItems?: number, maxItems?: number, allowed?: any[], pattern?: RegExp }[]} schema + * @param {object|Array} args + * @returns {object|Array} validated args (same shape as input) + * @throws {InvalidInputError} + */ +function validateArgs(schema, args) { + if (!Array.isArray(schema)) { + throw new InvalidInputError('Schema must be an array of validation rules.'); + } + + const source = Array.isArray(args) ? args : { ...args }; + + for (const rule of schema) { + const { name, type, required } = rule; + let value; + + if (Array.isArray(source)) { + // positional args: schema entries are matched by index + const idx = schema.indexOf(rule); + value = source[idx]; + } else { + value = source[name]; + } + + if (required && (value === undefined || value === null)) { + throw new InvalidInputError(`Missing required argument: ${name}`); + } + + if (value === undefined || value === null) { + continue; + } + + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== type) { + throw new InvalidInputError( + `Argument "${name}" must be of type ${type}, got ${actualType}.` + ); + } + + if (type === 'string') { + const str = String(value); + if (str.length === 0) { + throw new InvalidInputError(`Argument "${name}" must be a non-empty string.`); + } + if (rule.pattern && !rule.pattern.test(str)) { + throw new InvalidInputError( + `Argument "${name}" does not match required pattern.` + ); + } + if (rule.allowed && !rule.allowed.includes(str)) { + throw new InvalidInputError( + `Argument "${name}" must be one of: ${rule.allowed.join(', ')}.` + ); + } + } + + if (type === 'number') { + const num = Number(value); + if (!Number.isFinite(num)) { + throw new InvalidInputError(`Argument "${name}" must be a finite number.`); + } + if (rule.min != null && num < rule.min) { + throw new InvalidInputError( + `Argument "${name}" must be >= ${rule.min}.` + ); + } + if (rule.max != null && num > rule.max) { + throw new InvalidInputError( + `Argument "${name}" must be <= ${rule.max}.` + ); + } + } + + if (type === 'array') { + const arr = value; + if (rule.minItems != null && arr.length < rule.minItems) { + throw new InvalidInputError( + `Argument "${name}" must contain at least ${rule.minItems} item(s).` + ); + } + if (rule.maxItems != null && arr.length > rule.maxItems) { + throw new InvalidInputError( + `Argument "${name}" must contain at most ${rule.maxItems} item(s).` + ); + } + } + } + + return source; +} + +module.exports = { validateArgs }; diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 775a7f2..5e4b85b 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -52,6 +52,7 @@ function registerIpcHandlers(mainWindow, services) { startNetworkStatsTimer: services.startNetworkStatsTimer, stopNetworkStatsTimer: services.stopNetworkStatsTimer, emergencyLockdown: services.emergencyLockdown, + isActuallyAdmin: services.isActuallyAdmin, }; registerScan(mainWindow, servicesForScan); diff --git a/src/main/lifecycle.js b/src/main/lifecycle.js new file mode 100644 index 0000000..6fc8b51 --- /dev/null +++ b/src/main/lifecycle.js @@ -0,0 +1,511 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { app, BrowserWindow, ipcMain } = require('electron'); +const { execFileSync } = require('child_process'); +const logger = require('../utils/logger'); +const i18n = require('../i18n'); +const featureFlags = require('../core/featureFlags'); +const { loadPlugins } = require('../core/pluginLoader'); +const serviceRegistry = require('./serviceRegistry'); +const updater = require('./updater'); +const { getTrayHealthSummary } = require('./healthSummary'); +const { initTrayDashboard } = require('./trayDashboard'); +const { registerIpcHandlers } = require('./ipcHandlers'); +const { MaintenanceScheduler } = require('./maintenanceScheduler'); +const windowManager = require('./windowManager'); + +function logLine(level, message, meta) { + const fn = logger[level] || logger.info; + fn(message, meta || undefined); +} + +function peekUiLanguage(dbPath) { + try { + if (!fs.existsSync(dbPath)) return 'en'; + const Database = require('better-sqlite3'); + const peek = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = peek.prepare('SELECT value FROM settings WHERE key = ?').get('ui.language'); + if (!row || row.value == null) return 'en'; + return JSON.parse(row.value); + } finally { + peek.close(); + } + } catch (_) { + return 'en'; + } +} + +function peekUiTheme(dbPath) { + try { + if (!fs.existsSync(dbPath)) return 'dark'; + const Database = require('better-sqlite3'); + const peek = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = peek.prepare('SELECT value FROM settings WHERE key = ?').get('ui.theme'); + if (!row || row.value == null) return 'dark'; + return JSON.parse(row.value); + } finally { + peek.close(); + } + } catch (_) { + return 'dark'; + } +} + +function getLocale(dbRef, startupLocale) { + if (dbRef) { + try { + const lang = dbRef.getSetting('ui.language', 'en'); + return i18n.normalizeLocale(lang); + } catch (_) { + return startupLocale; + } + } + return startupLocale; +} + +function t(key, vars) { + return i18n.t(key, getLocale(windowManager.dbRef, windowManager.startupLocale), vars); +} + +function wireServices(db, eventBus, options = {}) { + const notify = options.notify || (() => {}); + const locale = options.locale || 'en'; + const services = serviceRegistry.create(db, eventBus, { + userDataPath: options.userDataPath, + locale, + notify, + }); + return services; +} + +function initUpdater(services, options = {}) { + const notify = options.notify || (() => {}); + updater.initAutoUpdater({ onNotify: (title, body, level) => notify(title, body, level) }); + updater.subscribe((status) => { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send('update:status', status); + } + }); +} + +function initTray(services, options = {}) { + const { mainWindow, app } = options; + const db = options.db; + const toolRegistry = services.toolRegistry; + try { + const trayController = initTrayDashboard({ + app, + mainWindow, + getSummary: () => getTrayHealthSummary(db, toolRegistry) + }); + services.trayController = trayController; + return trayController; + } catch (err) { + logLine('warn', 'Tray dashboard unavailable', { error: err.message }); + return null; + } +} + +function registerProgressListeners(services, db) { + const { mainWindow, eventBus } = services; + if (!mainWindow || mainWindow.isDestroyed()) return; + if (services._progressListenersRegistered) return; + services._progressListenersRegistered = true; + + const resolveScanType = (data) => data?.scanType || data?.report?.scanType || null; + const isBackgroundScan = (scanType) => scanType === 'folderwatch'; + let announcedProgress = new Set(); + + eventBus.on('scan:progress', (data) => { + const scanType = resolveScanType(data); + if (!isBackgroundScan(scanType) && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('scan:progress', data); + } + if (!data || typeof data.pct !== 'number') return; + if (db && !featureFlags.getFlag(db, 'scanNotifications', true)) return; + if (scanType === 'definitions' || isBackgroundScan(scanType) || scanType === 'custom') return; + const milestone = [0, 25, 50, 75].find((value) => data.pct >= value && !announcedProgress.has(value)); + if (milestone !== undefined) { + announcedProgress.add(milestone); + const files = data.filesScanned || 0; + services.notify && services.notify('toast.scanProgressTitle', 'scan.progress', 'info', { files, pct: data.pct }); + } + }); + + eventBus.on('scan:complete', (data) => { + const scanType = resolveScanType(data); + announcedProgress.clear(); + if (!isBackgroundScan(scanType) && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('scan:complete', data); + } + if (isBackgroundScan(scanType) || scanType === 'custom') return; + + let label, body, level; + if (data.scanType === 'definitions') { + if (data.status === 'completed') { + label = 'toast.signaturesUpdated'; + body = 'toast.definitionsUpdatedDetail'; + level = 'success'; + } else if (data.status === 'canceled') { + label = 'toast.definitionsUpdateCanceled'; + body = 'toast.definitionsUpdateCanceledDetail'; + level = 'warn'; + } else { + label = 'toast.definitionsUpdateFailed'; + body = data.error || 'toast.definitionsUpdateFailedDetail'; + level = 'danger'; + } + } else { + if (data.status === 'canceled') { + label = 'toast.scanCanceled'; + body = 'toast.scanCanceledDetail'; + level = 'warn'; + } else { + label = data.status === 'completed' ? 'toast.scanCompleted' : 'toast.scanFinishedWithIssues'; + body = 'toast.scanSummary'; + level = data.status !== 'completed' ? 'warn' : (data.threatsFound ? 'danger' : 'success'); + } + } + const iconOverride = (data.threatsFound && data.threatsFound > 0) ? windowManager.TOAST_ICONS.threat : null; + services.notify && services.notify(label, body, level, iconOverride); + + (async () => { + try { + 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...'); + const result = await services.toolRegistry.run('generate-security-report', { version: app.getVersion() }, { + toolRegistry: services.toolRegistry, + db, + log: logLine + }); + logLine('info', 'Scan report ' + (result.ok ? 'generated' : 'failed: ' + (result.error || 'unknown'))); + } catch (err) { + logLine('error', 'Auto-report generation threw: ' + (err.message || err)); + } + })(); + }); +} + +async function startBackgroundEngines(services, db) { + const { clamEngine, realtimeWatcher, folderWatcher, networkAlertMonitor, blocklistService, networkMonitor } = services; + + try { + await clamEngine.init(); + } catch (err) { + logLine('error', 'ClamAV init failed', { message: err.message }); + } + try { + if (featureFlags.getFlag(db, 'realtimeProtection', true)) { + await realtimeWatcher.start(); + } + } catch (err) { + logLine('error', 'Real-time protection init failed', { message: err.message }); + } + try { + if (featureFlags.getFlag(db, 'folderWatch', true)) { + folderWatcher.start(); + } + } catch (err) { + logLine('error', 'Folder watcher init failed', { message: err.message }); + } + try { + if (featureFlags.getFlag(db, 'networkAlerts', true)) { + networkAlertMonitor.start(); + } + } catch (err) { + logLine('error', 'Network alert monitor init failed', { message: err.message }); + } + try { + await blocklistService.refreshAll(); + } catch (err) { + logLine('error', 'Blocklist refresh failed', { message: err.message }); + } + if (featureFlags.getFlag(db, 'networkTrafficHistory', true)) { + services.startNetworkStatsTimer && services.startNetworkStatsTimer(); + } + try { + await networkMonitor.getStats(); + } catch (err) { + logLine('error', 'Network stats warm-up failed', { message: err.message }); + } +} + +async function start(db, eventBus, options = {}) { + const { userDataPath, notify } = options; + const locale = getLocale(db, options.startupLocale || 'en'); + + // 2. Security Engines (Dependency Injection) + const services = wireServices(db, eventBus, { + userDataPath, + locale, + notify: (title, body, level) => notify(title, body, level), + }); + services.notify = notify; + + // Probe actual admin state once at startup rather than assuming elevation + // from the NSIS requestedExecutionLevel. The running process can lose + // elevation, and the renderer needs the real value for UI decisions. + let isActuallyAdmin = false; + try { + const adminResult = await services.systemAudit.runPowerShell( + "([Security.Principal.WindowsPrincipal] " + + "[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(" + + "[Security.Principal.WindowsBuiltInRole]::Administrator)" + ); + isActuallyAdmin = adminResult.ok && adminResult.stdout.trim() === 'True'; + } catch (_) { + isActuallyAdmin = false; + } + services.isActuallyAdmin = isActuallyAdmin; + + // Network stats timer control (for feature toggle) + services.startNetworkStatsTimer = () => { + if (services._networkStatsTimer) return { running: true }; + const sampleNetworkStats = async () => { + try { + const stats = await services.networkMonitor.getStats(); + const recordedAt = new Date().toISOString(); + for (const iface of (stats.interfaces || [])) { + db.addNetworkStatsSample(iface.iface, iface.rxSec || 0, iface.txSec || 0, recordedAt); + } + } catch (err) { + logLine('warn', 'Network stats sample failed', { message: err.message }); + } + }; + const networkStatsTimer = setInterval(sampleNetworkStats, 30_000); + if (typeof networkStatsTimer.unref === 'function') networkStatsTimer.unref(); + services._networkStatsTimer = networkStatsTimer; + sampleNetworkStats().catch(() => {}); + return { running: true }; + }; + services.stopNetworkStatsTimer = () => { + if (services._networkStatsTimer) { + clearInterval(services._networkStatsTimer); + services._networkStatsTimer = null; + } + return { running: false }; + }; + + const maintenanceScheduler = new MaintenanceScheduler({ + db, + toolRegistry: services.toolRegistry, + getIdleTimeSeconds: () => { + try { return require('electron').powerMonitor.getSystemIdleTime(); } catch (_) { return 0; } + }, + notify: (title, body, level) => notify(title, body, level), + log: (level, message, meta) => logLine(level, message, meta) + }); + maintenanceScheduler.start(); + services.maintenanceScheduler = maintenanceScheduler; + + initUpdater(services, { notify }); + loadPlugins(); + windowManager.sendSplashProgress(services.splashWindow, 6, t('splash.loadingEngines')); + + // Show the window as soon as possible instead of waiting on ClamAV/RTP + // initialization below -- those can take a while (definitions download, + // spawning PowerShell) and previously blocked the window from appearing + // at all until they finished. + windowManager.buildAppMenu(services.mainWindow); + const { mainWindow, splashTimeoutId } = windowManager.createWindow(); + services.mainWindow = mainWindow; + windowManager.sendSplashProgress(services.splashWindow, 9, t('splash.buildingInterface')); + + // Register IPC handlers only once mainWindow actually exists. + registerIpcHandlers(mainWindow, services); + windowManager.sendSplashProgress(services.splashWindow, 12, t('splash.registeringServices')); + + const trayController = initTray(services, { mainWindow, app, db, toolRegistry: services.toolRegistry }); + if (trayController) { + services.trayController = trayController; + } + + mainWindow.on('close', (event) => { + if (!services.isQuitting && trayController?.tray) { + event.preventDefault(); + mainWindow.hide(); + } + }); + + windowManager.sendSplashProgress(services.splashWindow, 15, t('splash.loadingDashboard')); + + // Extract icons from executable paths for the startup items tool + const _startupIconCache = {}; + ipcMain.handle('startup:getIcons', async (_event, exePaths) => { + const unique = [...new Set((exePaths || []).filter(Boolean))]; + const result = {}; + for (const exePath of unique) { + if (exePath in _startupIconCache) { + result[exePath] = _startupIconCache[exePath]; + continue; + } + try { + const expandedPath = process.env.SystemRoot && exePath.includes('%SystemRoot%') + ? exePath.replace(/%SystemRoot%/gi, process.env.SystemRoot) + : exePath; + if (!fs.existsSync(expandedPath)) { + _startupIconCache[exePath] = null; + result[exePath] = null; + continue; + } + const nativeImg = await app.getFileIcon(expandedPath); + const dataUrl = nativeImg.toDataURL(); + if (dataUrl && dataUrl.length > 100) { + _startupIconCache[exePath] = dataUrl; + result[exePath] = dataUrl; + } else { + _startupIconCache[exePath] = null; + result[exePath] = null; + } + } catch (_) { + _startupIconCache[exePath] = null; + result[exePath] = null; + } + } + return result; + }); + + // Extract icons from executable paths for the processes page + const _processIconCache = {}; + ipcMain.handle('process:getIcons', async (_event, exePaths) => { + const unique = [...new Set((exePaths || []).filter(Boolean))]; + const result = {}; + for (const exePath of unique) { + if (exePath in _processIconCache) { + result[exePath] = _processIconCache[exePath]; + continue; + } + try { + const expandedPath = process.env.SystemRoot && exePath.includes('%SystemRoot%') + ? exePath.replace(/%SystemRoot%/gi, process.env.SystemRoot) + : exePath; + if (!fs.existsSync(expandedPath)) { + _processIconCache[exePath] = null; + result[exePath] = null; + continue; + } + const nativeImg = await app.getFileIcon(expandedPath); + const dataUrl = nativeImg.toDataURL(); + if (dataUrl && dataUrl.length > 100) { + _processIconCache[exePath] = dataUrl; + result[exePath] = dataUrl; + } else { + _processIconCache[exePath] = null; + result[exePath] = null; + } + } catch (_) { + _processIconCache[exePath] = null; + result[exePath] = null; + } + } + return result; + }); + + // Enable/disable a startup item + ipcMain.handle('startup:toggle', async (_event, item, enable) => { + try { + if (item.source === 'registry') { + const hive = item.scope === 'HKLM' ? 'HKLM' : 'HKCU'; + const key = `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`; + if (enable) { + execFileSync('reg', ['add', key, '/v', item.name, '/t', 'REG_SZ', '/d', item.command, '/f'], { timeout: 10000 }); + } else { + execFileSync('reg', ['delete', key, '/v', item.name, '/f'], { timeout: 10000 }); + } + return { ok: true }; + } else if (item.source === 'startup-folder') { + const appData = process.env.APPDATA || ''; + const programData = process.env.ProgramData || ''; + const userStartup = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); + const allStartup = path.join(programData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); + const startupDir = item.scope === 'user' ? userStartup : allStartup; + if (enable) { + const backup = path.join(startupDir, '.disabled', item.name); + if (fs.existsSync(backup)) { + fs.renameSync(backup, item.path); + return { ok: true }; + } + return { ok: false, error: 'No backup found to restore' }; + } else { + const disabledDir = path.join(startupDir, '.disabled'); + fs.mkdirSync(disabledDir, { recursive: true }); + const dest = path.join(disabledDir, item.name); + fs.renameSync(item.path, dest); + return { ok: true }; + } + } + return { ok: false, error: 'Toggle not supported for this item type' }; + } catch (err) { + return { ok: false, error: err.message }; + } + }); + + // Toast navigation bridge: receives the signal from the isolated toast + // preload and forwards it to the main window renderer. + ipcMain.on('toast:navigate-scanner', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.focus(); + mainWindow.webContents.send('navigate-to-scanner'); + } + }); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) windowManager.createWindow(); + }); + + // Forward progress events from the dashboard (renderer) to the splash + ipcMain.handle('splash:progress', (_event, data) => { + if (services.splashWindow && !services.splashWindow.isDestroyed()) { + services.splashWindow.webContents.send('splash:progress', data); + } + }); + + // Register the scan progress listeners + registerProgressListeners(services, db); + + setTimeout(() => { + if (featureFlags.getFlag(db, 'autoUpdates', true)) { + updater.checkForUpdates().catch(() => {}); + } + }, 30_000); + + // Slow engine initialization (ClamAV definitions, real-time protection) + // runs in the background after the window is already visible, instead of + // blocking startup. + (async () => { + await startBackgroundEngines(services, db); + + const pruneTimer = setInterval(() => { + try { + db.pruneNetworkStats(7); + db.pruneMaintenanceRuns(100); + } catch (err) { + logLine('debug', 'Prune maintenance task failed', { error: err.message }); + } + }, 60 * 60_000); + if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); + services._pruneTimer = pruneTimer; + })(); + + return services; +} + +module.exports = { + wireServices, + initUpdater, + initTray, + loadPlugins, + start, + logLine, + peekUiLanguage, + peekUiTheme, + getLocale, + t, +}; diff --git a/src/main/main.js b/src/main/main.js index bb52dc4..a5f0772 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -1,11 +1,15 @@ -const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, screen, powerMonitor } = require('electron'); +'use strict'; + +const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, screen } = require('electron'); const { execFileSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); const logger = require('../utils/logger'); -const { TOAST_THEMES, resolveThemeName, themeBackground } = require('../utils/themes'); -const i18n = require('../i18n'); +const featureFlags = require('../core/featureFlags'); +const { resolveThemeName } = require('../utils/themes'); +const windowManager = require('./windowManager'); +const lifecycle = require('./lifecycle'); // Ensure Chromium/Electron uses a writable data/cache location instead of // falling back to a restricted or temp-based path on Windows. @@ -17,7 +21,7 @@ try { const tempDir = path.join(userDataPath, 'temp'); for (const dirPath of [userDataPath, cacheDir, tempDir]) { - try { fs.mkdirSync(dirPath, { recursive: true }); } catch (err) { logLine('warn', 'Failed to create directory: ' + dirPath, { error: err.message }); } + try { fs.mkdirSync(dirPath, { recursive: true }); } catch (err) { lifecycle.logLine('warn', 'Failed to create directory: ' + dirPath, { error: err.message }); } } app.setPath('userData', userDataPath); @@ -28,552 +32,16 @@ try { app.commandLine.appendSwitch('media-cache-dir', cacheDir); app.commandLine.appendSwitch('disable-http-cache'); app.commandLine.appendSwitch('disable-logging'); - // GPU acceleration is enabled by default -- disabling it forces Chromium - // into full software rendering, which is the most common cause of choppy - // scrolling/animations in Electron apps. If a specific machine hits a - // graphics driver crash or rendering corruption, set - // SOTERIOS_DISABLE_GPU=1 in the environment to fall back to software - // rendering without needing a code change. if (process.env.SOTERIOS_DISABLE_GPU === '1') { app.commandLine.appendSwitch('disable-gpu'); app.commandLine.appendSwitch('disable-gpu-compositing'); app.commandLine.appendSwitch('disable-software-rasterizer'); } - // Harmless regardless of GPU state -- avoids extra disk writes, not a - // rendering-smoothness switch. app.commandLine.appendSwitch('disable-gpu-shader-disk-cache'); app.commandLine.appendSwitch('disable-background-networking'); app.commandLine.appendSwitch('disable-features', 'NetworkService,AutofillServerCommunication,AutofillAcrossForms,Autofill'); -} catch (err) { - // If anything goes wrong here, we intentionally continue — these are best-effort mitigations -} - -const DatabaseService = require('../core/database'); -const eventBus = require('../core/eventBus'); -const { registerIpcHandlers } = require('./ipcHandlers'); -const serviceRegistry = require('./serviceRegistry'); -const { MaintenanceScheduler } = require('./maintenanceScheduler'); -const { initTrayDashboard } = require('./trayDashboard'); -const updater = require('./updater'); -const { getTrayHealthSummary } = require('./healthSummary'); - -// Legacy utilities -const { loadPlugins } = require('../core/pluginLoader'); -const featureFlags = require('../core/featureFlags'); - -let mainWindow; -let splashWindow; -let splashTimeoutId; -let dbRef; // set once the database is created in app.whenReady() below, so -// showNotification (defined before that point) can check settings -let currentUiTheme = 'dark'; -let startupLocale = 'en'; // set from peekUiLanguage() before the DB is ready, -// so the earliest splash messages respect the saved language -let isQuitting = false; -const lifecycleRefs = { - maintenanceScheduler: null, - trayController: null, - networkStatsTimer: null, - pruneTimer: null -}; - -function logLine(level, message, meta) { - const fn = logger[level] || logger.info; - fn(message, meta || undefined); -} - -function peekUiLanguage(dbPath) { - try { - if (!fs.existsSync(dbPath)) return 'en'; - const Database = require('better-sqlite3'); - const peek = new Database(dbPath, { readonly: true, fileMustExist: true }); - try { - const row = peek.prepare('SELECT value FROM settings WHERE key = ?').get('ui.language'); - if (!row || row.value == null) return 'en'; - return JSON.parse(row.value); - } finally { - peek.close(); - } - } catch (_) { - return 'en'; - } -} - -function peekUiTheme(dbPath) { - try { - if (!fs.existsSync(dbPath)) return 'dark'; - const Database = require('better-sqlite3'); - const peek = new Database(dbPath, { readonly: true, fileMustExist: true }); - try { - const row = peek.prepare('SELECT value FROM settings WHERE key = ?').get('ui.theme'); - if (!row || row.value == null) return 'dark'; - return JSON.parse(row.value); - } finally { - peek.close(); - } - } catch (_) { - return 'dark'; - } -} - -function getLocale() { - if (dbRef) { - try { - const lang = dbRef.getSetting('ui.language', 'en'); - return i18n.normalizeLocale(lang); - } catch (_) { - return startupLocale; - } - } - return startupLocale; -} - -function t(key, vars) { - return i18n.t(key, getLocale(), vars); -} - -function createIcon() { - const iconPath = path.join(__dirname, '../../assets/icon.ico'); - return nativeImage.createFromPath(iconPath); -} - -// -- Custom-designed toast notifications --------------------------------- -// Electron's built-in Notification API renders through the OS's native -// toast template (title/body/icon only) -- there's no way to apply -// Soterios's own dark/cyan design to it. These are small frameless windows -// we fully control instead, stacked bottom-right and styled to match the -// rest of the app. -const activeToasts = []; -const TOAST_WIDTH = 380; -const TOAST_HEIGHT = 180; -const TOAST_MARGIN = 16; -const TOAST_GAP = 10; -const TOAST_LIFETIME_MS = 6000; - -// Toast HTML is loaded via a data: URL, which has no filesystem base to -// resolve a relative image path against -- so the logo is embedded directly -// as a base64 PNG instead of referenced by path. Computed once and cached -// since it never changes. -function readPngAsDataUri(relativePath) { - try { - const fullPath = path.join(__dirname, '../../', relativePath); - const buf = fs.readFileSync(fullPath); - return `data:image/png;base64,${buf.toString('base64')}`; - } catch (_) { - return ''; - } -} - -function getToastMarkDataUri() { - if (!getToastMarkDataUri._cache) getToastMarkDataUri._cache = readPngAsDataUri('assets/toast-icon.png'); - return getToastMarkDataUri._cache; -} - -function getToastWordmarkDataUri() { - if (!getToastWordmarkDataUri._cache) getToastWordmarkDataUri._cache = readPngAsDataUri('assets/toast-wordmark.png'); - return getToastWordmarkDataUri._cache; -} - -const TOAST_ACCENTS = { - info: '#4fc3d9', - success: '#3ddc97', - warn: '#e8b339', - danger: '#e85f5c' -}; - -const TOAST_ICONS = { - info: '', - success: '', - warn: '', - danger: '', - threat: '' -}; - -function escToastHtml(v) { - return String(v ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); -} - -function toastHtml(title, body, level, themeName, iconOverride = null) { - const theme = TOAST_THEMES[themeName] || TOAST_THEMES.dark; - const accent = theme.accents[level] || theme.accents.info; - const iconPaths = iconOverride || TOAST_ICONS[level] || TOAST_ICONS.info; - const markDataUri = getToastMarkDataUri(); - const wordmarkDataUri = getToastWordmarkDataUri(); - return ` - - -
-
- ${markDataUri ? `` : ''} - ${wordmarkDataUri ? `` : 'Soterios'} -
-
×
-
-
-
- ${iconPaths} -
-
-
${escToastHtml(title)}
-
${escToastHtml(body)}
-
-
-
- -`; -} - -// Newest toast lands closest to the bottom margin; older ones already on -// screen get pushed upward above it, same stacking behavior as Windows' -// own Action Center toasts. -function repositionToasts() { - const display = screen.getPrimaryDisplay(); - const { x, y, width, height } = display.workArea; - let bottom = y + height - TOAST_MARGIN; - for (let i = activeToasts.length - 1; i >= 0; i--) { - const win = activeToasts[i]; - if (!win || win.isDestroyed()) continue; - const top = bottom - TOAST_HEIGHT; - win.setBounds({ x: x + width - TOAST_WIDTH - TOAST_MARGIN, y: top, width: TOAST_WIDTH, height: TOAST_HEIGHT }); - bottom = top - TOAST_GAP; - } -} - -function showNotification(title, body, level = 'info', iconOverride = null) { - if (dbRef && !featureFlags.getFlag(dbRef, 'notificationsEnabled', true)) return; - try { - const themeName = dbRef ? dbRef.getSetting('ui.theme', 'dark') : 'dark'; - const display = screen.getPrimaryDisplay(); - const { x, y, width, height } = display.workArea; - const toastWindow = new BrowserWindow({ - width: TOAST_WIDTH, - height: TOAST_HEIGHT, - x: x + width - TOAST_WIDTH - TOAST_MARGIN, - y: y + height - TOAST_HEIGHT - TOAST_MARGIN, - frame: false, - transparent: true, - resizable: false, - movable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - skipTaskbar: true, - alwaysOnTop: true, - focusable: false, - hasShadow: false, - show: false, - webPreferences: { - contextIsolation: false, - nodeIntegration: true, - sandbox: false - } - }); - // Translate title and body before rendering - const translatedTitle = t(title); - const translatedBody = t(body); - toastWindow.setAlwaysOnTop(true, 'screen-saver'); - toastWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(toastHtml(translatedTitle, translatedBody, level, themeName, iconOverride))); - toastWindow.once('ready-to-show', () => toastWindow.show()); - toastWindow.on('closed', () => { - const idx = activeToasts.indexOf(toastWindow); - if (idx !== -1) activeToasts.splice(idx, 1); - repositionToasts(); - }); - - // Handle toast click to navigate to scanner - toastWindow.webContents.on('will-navigate', (event, url) => { - if (url === 'soterios://navigate-scanner') { - event.preventDefault(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.focus(); - mainWindow.webContents.send('navigate-to-scanner'); - } - } - }); - - activeToasts.push(toastWindow); - repositionToasts(); - } catch (_) { } -} - -function createSplashWindow(themeName = 'dark') { - const theme = resolveThemeName(themeName); - splashWindow = new BrowserWindow({ - width: 660, - height: 440, - frame: false, - resizable: false, - movable: true, - minimizable: false, - maximizable: false, - fullscreenable: false, - show: false, - center: true, - skipTaskbar: true, - backgroundColor: themeBackground(theme), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - preload: path.join(__dirname, '../preload/splashPreload.js') - } - }); - - splashWindow.loadFile(path.join(__dirname, '../ui/pages/splash.html'), { - query: { theme } - }); - splashWindow.once('ready-to-show', () => { - if (splashWindow) splashWindow.show(); - }); -} - -function sendSplashProgress(pct, label) { - if (splashWindow && !splashWindow.isDestroyed()) { - splashWindow.webContents.send('splash:progress', { pct, label }); - } -} - -function isScreenshotCaptureMode() { - return process.argv.includes('--screenshot-capture'); -} - -function getScreenshotConfig() { - if (!isScreenshotCaptureMode()) return null; - const pageArg = process.argv.find((arg) => arg.startsWith('--screenshot-page=')); - const outArg = process.argv.find((arg) => arg.startsWith('--screenshot-out=')); - if (!pageArg || !outArg) return null; - const page = pageArg.split('=').slice(1).join('='); - const outPath = outArg.split('=').slice(1).join('='); - if (!page || !outPath) return null; - return { - page, - outPath, - runUninstaller: process.argv.includes('--screenshot-run-uninstaller') - }; -} - -function failScreenshotCapture(message) { - logLine('error', message); - app.exit(1); -} - -function scheduleScreenshotCapture(win, config) { - win.webContents.once('did-finish-load', () => { - dismissSplash(); - if (config.page === 'tools') win.setSize(1280, 980); - const delayMs = config.runUninstaller ? 2000 : 8000; - setTimeout(async () => { - try { - if (config.page === 'tools') { - await win.webContents.executeJavaScript(` - (async () => { - await new Promise((resolve) => setTimeout(resolve, 1500)); - document.querySelector('[data-script-id="uninstaller-report"]')?.scrollIntoView({ block: 'center' }); - })(); - `); - await new Promise((resolve) => setTimeout(resolve, 800)); - } - if (config.runUninstaller) { - await win.webContents.executeJavaScript(` - (async () => { - let clicked = false; - for (let attempt = 0; attempt < 24; attempt += 1) { - const btn = document.querySelector('[data-script-id="uninstaller-report"]'); - if (btn && !btn.disabled) { - btn.scrollIntoView({ block: 'center' }); - btn.click(); - clicked = true; - break; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!clicked) throw new Error('Uninstaller report button was not available'); - - for (let attempt = 0; attempt < 60; attempt += 1) { - const output = document.getElementById('toolOutput'); - const running = output && output.querySelector('.spinner'); - const hasContent = output && output.textContent && output.textContent.trim().length > 20; - if (!running && hasContent) { - output.scrollIntoView({ block: 'start' }); - return true; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error('Uninstaller report did not finish in time'); - })(); - `); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - const image = await win.webContents.capturePage(); - fs.mkdirSync(path.dirname(config.outPath), { recursive: true }); - fs.writeFileSync(config.outPath, image.toPNG()); - logLine('info', 'Screenshot saved', { path: config.outPath }); - app.quit(); - } catch (err) { - logLine('error', 'Screenshot capture failed', { error: err.message }); - process.exitCode = 1; - app.quit(); - } - }, delayMs); - }); -} - -// Called once the renderer's Dashboard has actually finished loading its data -// (not just once the HTML has parsed), or after a maximum wait as a fallback -// so a slow/failed load never leaves the user stuck looking at the splash -// screen forever. -function dismissSplash() { - if (splashTimeoutId) { - clearTimeout(splashTimeoutId); - splashTimeoutId = undefined; - } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show(); - } - if (splashWindow && !splashWindow.isDestroyed()) { - splashWindow.close(); - } - splashWindow = undefined; -} - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 820, - minWidth: 980, - minHeight: 640, - backgroundColor: themeBackground(currentUiTheme), - title: 'Soterios', - icon: createIcon(), - autoHideMenuBar: true, - webPreferences: { - preload: path.join(__dirname, '../preload/preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true - }, - show: false - }); - - const shellHtmlPath = path.join(__dirname, '../ui/pages/shell.html'); - const screenshotConfig = getScreenshotConfig(); - if (isScreenshotCaptureMode() && !screenshotConfig) { - failScreenshotCapture('Screenshot capture requires --screenshot-page= and --screenshot-out='); - return; - } - if (screenshotConfig) { - mainWindow.loadFile(shellHtmlPath, { hash: screenshotConfig.page }); - scheduleScreenshotCapture(mainWindow, screenshotConfig); - } else { - mainWindow.loadFile(shellHtmlPath); - } - - // Intentionally no auto-show on 'ready-to-show' here -- the window stays - // hidden until the renderer signals it has actually finished loading data - // (see the 'app:ready' handler below), so the splash screen covers the - // whole load instead of just the initial blank-page flash. A fallback - // timeout guarantees the window still appears even if that signal is - // delayed or never arrives (e.g. an unexpected renderer error). - splashTimeoutId = setTimeout(dismissSplash, 8000); - - if ((process.argv.includes('--dev') || process.env.NODE_ENV === 'development') && !isScreenshotCaptureMode()) { - mainWindow.webContents.once('did-finish-load', () => { - mainWindow.webContents.openDevTools({ mode: 'detach' }); - }); - } -} - -function buildAppMenu() { - const isMac = process.platform === 'darwin'; - - const aboutHandler = () => { - dialog.showMessageBox(mainWindow, { - type: 'info', - title: 'About Soterios', - message: 'Soterios', - detail: `Version ${app.getVersion()}\n\nLocal-first Windows security and maintenance platform.`, - buttons: ['OK'] - }); - }; - - const template = [ - { - label: 'File', - submenu: [isMac ? { role: 'close' } : { role: 'quit' }] - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' } - ] - }, - { - label: 'Help', - submenu: [ - { label: 'About Soterios', click: aboutHandler } - ] - } - ]; - - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} catch (_) { + // Best-effort mitigations — continue on failure. } app.setAppUserModelId('com.soterios.app'); @@ -585,22 +53,20 @@ if (!gotTheLock) { } app.on('second-instance', (_event, commandLine) => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); + if (windowManager.mainWindow) { + if (windowManager.mainWindow.isMinimized()) windowManager.mainWindow.restore(); + windowManager.mainWindow.focus(); const url = commandLine.find(arg => arg.startsWith('soterios://')); - if (url) mainWindow.webContents.send('protocol-url', url); + if (url) windowManager.mainWindow.webContents.send('protocol-url', url); } }); app.whenReady().then(async () => { - // Register custom protocol for browser extension communication if (process.platform === 'win32') { app.setAsDefaultProtocolClient('soterios'); } const dbPath = path.join(app.getPath('userData'), 'soterios.db'); - // File logging is opt-in via SOTERIOS_LOG_FILE (path or "1" for the default log file). const logConfig = { level: process.env.SOTERIOS_LOG_LEVEL || 'info' }; if (process.env.SOTERIOS_LOG_FILE) { logConfig.filePath = process.env.SOTERIOS_LOG_FILE === '1' @@ -609,24 +75,36 @@ app.whenReady().then(async () => { } logger.configure(logConfig); - // Peek the saved theme before creating the splash so the first paint - // matches the user's preference instead of always flashing dark mode. - currentUiTheme = peekUiTheme(dbPath); - // Peek the saved locale too, so the earliest splash progress messages - // are in the user's language instead of always starting in English. - startupLocale = i18n.normalizeLocale(peekUiLanguage(dbPath)); - if (!isScreenshotCaptureMode()) { - createSplashWindow(currentUiTheme); - } + const currentUiTheme = lifecycle.peekUiTheme(dbPath); + const startupLocale = lifecycle.peekUiLanguage(dbPath); + windowManager.init({ + dbRef: null, + featureFlags, + currentUiTheme, + startupLocale, + logLine: lifecycle.logLine, + t: lifecycle.t.bind(lifecycle), + }); - logLine('info', 'App starting', { theme: currentUiTheme }); - sendSplashProgress(0, t('splash.starting')); + if (!windowManager.isScreenshotCaptureMode()) { + windowManager.createSplashWindow(currentUiTheme); + } + lifecycle.logLine('info', 'App starting', { theme: currentUiTheme }); + windowManager.sendSplashProgress(windowManager.splashWindow, 0, lifecycle.t('splash.starting')); // 1. Database + const DatabaseService = require('../core/database'); const db = new DatabaseService(dbPath); - dbRef = db; - currentUiTheme = resolveThemeName(db.getSetting('ui.theme', currentUiTheme)); - sendSplashProgress(3, t('splash.connectingDb')); + windowManager.init({ + dbRef: db, + featureFlags, + currentUiTheme: resolveThemeName(db.getSetting('ui.theme', currentUiTheme)), + startupLocale, + logLine: lifecycle.logLine, + t: lifecycle.t.bind(lifecycle), + }); + lifecycle.logLine('info', 'Database connected', { path: dbPath }); + windowManager.sendSplashProgress(windowManager.splashWindow, 3, lifecycle.t('splash.connectingDb')); // Migrate old feature.systemMonitoring key to feature.externalLookups const oldVal = db.getSetting('feature.systemMonitoring', null); @@ -636,441 +114,64 @@ app.whenReady().then(async () => { db.setSetting('feature.systemMonitoring', null); } - // 2. Security Engines (Dependency Injection) - const services = serviceRegistry.create(db, eventBus, { - userDataPath: app.getPath('userData'), - locale: getLocale(), - notify: (title, body, level) => showNotification(t(title), t(body), level), - }); - - // Network stats timer control (for feature toggle) - services.startNetworkStatsTimer = () => { - if (lifecycleRefs.networkStatsTimer) return { running: true }; - const sampleNetworkStats = async () => { - try { - const stats = await networkMonitor.getStats(); - const recordedAt = new Date().toISOString(); - for (const iface of (stats.interfaces || [])) { - db.addNetworkStatsSample(iface.iface, iface.rxSec || 0, iface.txSec || 0, recordedAt); - } - } catch (err) { - logLine('warn', 'Network stats sample failed', { message: err.message }); - } - }; - const networkStatsTimer = setInterval(sampleNetworkStats, 30_000); - if (typeof networkStatsTimer.unref === 'function') networkStatsTimer.unref(); - lifecycleRefs.networkStatsTimer = networkStatsTimer; - sampleNetworkStats().catch(() => {}); - return { running: true }; - }; - services.stopNetworkStatsTimer = () => { - if (lifecycleRefs.networkStatsTimer) { - clearInterval(lifecycleRefs.networkStatsTimer); - lifecycleRefs.networkStatsTimer = null; - } - return { running: false }; - }; - - const featureFlags = require('../core/featureFlags'); - const { getFlag: getFeatureFlag } = featureFlags; + const eventBus = require('../core/eventBus'); - const maintenanceScheduler = new MaintenanceScheduler({ - db: services.db, - toolRegistry: services.toolRegistry, - getIdleTimeSeconds: () => { - try { return powerMonitor.getSystemIdleTime(); } catch (_) { return 0; } - }, - notify: (title, body, level) => showNotification(t(title), t(body), level), - log: (level, message, meta) => logLine(level, message, meta) - }); - maintenanceScheduler.start(); - services.maintenanceScheduler = maintenanceScheduler; - lifecycleRefs.maintenanceScheduler = maintenanceScheduler; - - const { - clamEngine, - realtimeWatcher, - folderWatcher, - networkAlertMonitor, - blocklistService, - networkMonitor, - toolRegistry - } = services; - - updater.initAutoUpdater({ onNotify: (title, body, level) => showNotification(t(title), t(body), level) }); - updater.subscribe((status) => { - for (const win of BrowserWindow.getAllWindows()) { - if (!win.isDestroyed()) win.webContents.send('update:status', status); - } + const services = lifecycle.start(db, eventBus, { + userDataPath: app.getPath('userData'), + startupLocale, + notify: (title, body, level) => windowManager.showNotification(lifecycle.t(title), lifecycle.t(body), level), }); - // loadPlugins() is a synchronous filesystem scan, not a network call, so - // it's cheap enough to keep here rather than deferring it. - loadPlugins(); - sendSplashProgress(6, t('splash.loadingEngines')); - - // Show the window as soon as possible instead of waiting on ClamAV/RTP - // initialization below -- those can take a while (definitions download, - // spawning PowerShell) and previously blocked the window from appearing - // at all until they finished. - buildAppMenu(); - createWindow(); - sendSplashProgress(9, t('splash.buildingInterface')); - - // Register IPC handlers only once mainWindow actually exists. Previously - // this ran before createWindow(), so the mainWindow parameter passed in - // was always undefined (a plain variable copied by value at call time) -- - // handlers like dialog:pickFolder/pickFiles silently fell back to - // BrowserWindow.getFocusedWindow() instead of targeting the real window. - registerIpcHandlers(mainWindow, services); - sendSplashProgress(12, t('splash.registeringServices')); - - try { - lifecycleRefs.trayController = initTrayDashboard({ - app, - mainWindow, - getSummary: () => getTrayHealthSummary(db, toolRegistry) - }); - services.trayController = lifecycleRefs.trayController; - - mainWindow.on('close', (event) => { - if (!isQuitting && lifecycleRefs.trayController?.tray) { - event.preventDefault(); - mainWindow.hide(); - } - }); - } catch (err) { - logLine('warn', 'Tray dashboard unavailable', { error: err.message }); - } - - setTimeout(() => { - if (featureFlags.getFlag(db, 'autoUpdates', true)) { - updater.checkForUpdates().catch(() => {}); - } - }, 30_000); - - // Module-level tracking for announced progress milestones to prevent duplicate notifications - let announcedProgress = new Set(); - let progressListenersRegistered = false; - - function registerScanProgressListeners() { - if (progressListenersRegistered) return; - progressListenersRegistered = true; - - const resolveScanType = (data) => data?.scanType || data?.report?.scanType || null; - const isBackgroundScan = (scanType) => scanType === 'folderwatch'; - - eventBus.on('scan:progress', (data) => { - const scanType = resolveScanType(data); - // Don't forward folder watch progress to UI to prevent interference - if (!isBackgroundScan(scanType) && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('scan:progress', data); - } - if (!data || typeof data.pct !== 'number') 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)); - if (milestone !== undefined) { - announcedProgress.add(milestone); - const files = data.filesScanned || 0; - showNotification(t('toast.scanProgressTitle'), t('scan.progress', { files, pct: data.pct }), 'info'); - } - }); - - // Forward scan complete events to renderer - eventBus.on('scan:complete', (data) => { - const scanType = resolveScanType(data); - // Clear announced progress milestones when scan completes - announcedProgress.clear(); - if (!isBackgroundScan(scanType) && mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('scan:complete', data); - } - if (isBackgroundScan(scanType) || scanType === 'custom') return; - - let label; - let body; - let level; - if (data.scanType === 'definitions') { - if (data.status === 'completed') { - label = t('toast.signaturesUpdated'); - body = t('toast.definitionsUpdatedDetail'); - level = 'success'; - } else if (data.status === 'canceled') { - label = t('toast.definitionsUpdateCanceled'); - body = t('toast.definitionsUpdateCanceledDetail'); - level = 'warn'; - } else { - label = t('toast.definitionsUpdateFailed'); - body = data.error || t('toast.definitionsUpdateFailedDetail'); - level = 'danger'; - } - } else { - // Only show notification if not canceled - if (data.status === 'canceled') { - label = t('toast.scanCanceled'); - body = t('toast.scanCanceledDetail', { count: data.filesScanned || 0 }); - level = 'warn'; - } else { - label = data.status === 'completed' ? t('toast.scanCompleted') : t('toast.scanFinishedWithIssues'); - body = t('toast.scanSummary', { files: data.filesScanned || 0, threats: data.threatsFound || 0 }); - level = data.status !== 'completed' ? 'warn' : (data.threatsFound ? 'danger' : 'success'); - } - } - const iconOverride = (data.threatsFound && data.threatsFound > 0) ? TOAST_ICONS.threat : null; - showNotification(label, body, level, iconOverride); - // Auto-generate a scan report - (async () => { - try { - 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...'); - - const result = await toolRegistry.run('generate-security-report', { version: app.getVersion() }, { toolRegistry, db, log: logLine }); - logLine('info', 'Scan report ' + (result.ok ? 'generated' : 'failed: ' + (result.error || 'unknown'))); - } catch (err) { - logLine('error', 'Auto-report generation threw: ' + (err.message || err)); - } - })(); - }); - } - - // Register the scan progress listeners - registerScanProgressListeners(); - - // 4. Expose legacy utilities - // Expose legacy utility running mechanism - ipcMain.handle('tools:list', () => toolRegistry.list()); - ipcMain.handle('tools:run', async (event, toolId, args) => { - // Note: appStore is removed, so we mock it for utilities if needed - // or just let them use basic features. - return toolRegistry.run(toolId, args, { - toolRegistry, - db, - log: logLine, - sendProgress: (payload) => { - event.sender.send(`tools:progress:${toolId}`, payload); - } - }); - }); + // Keep a reference for IPC handlers that still reach into main.js state. + windowManager.mainWindow = services.mainWindow; + // splashWindow was created earlier via windowManager.createSplashWindow(); + // do NOT overwrite it with services.splashWindow (which is undefined). + windowManager.splashTimeoutId = services.splashTimeoutId; + // Renderer signals it has finished loading data; dismiss the splash. ipcMain.handle('app:ready', () => { - dismissSplash(); - }); - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); - }); - - // Forward progress events from the dashboard (renderer) to the splash - ipcMain.handle('splash:progress', (_event, data) => { - if (splashWindow && !splashWindow.isDestroyed()) { - splashWindow.webContents.send('splash:progress', data); - } - }); - - sendSplashProgress(15, t('splash.loadingDashboard')); - - // Extract icons from executable paths for the startup items tool - const _startupIconCache = {}; - ipcMain.handle('startup:getIcons', async (_event, exePaths) => { - const unique = [...new Set((exePaths || []).filter(Boolean))]; - const result = {}; - for (const exePath of unique) { - if (exePath in _startupIconCache) { - result[exePath] = _startupIconCache[exePath]; - continue; - } - try { - // Expand environment variables like %SystemRoot% - const expandedPath = process.env.SystemRoot && exePath.includes('%SystemRoot%') - ? exePath.replace(/%SystemRoot%/gi, process.env.SystemRoot) - : exePath; - // Only attempt if file exists - if (!fs.existsSync(expandedPath)) { - _startupIconCache[exePath] = null; - result[exePath] = null; - continue; - } - const nativeImg = await app.getFileIcon(expandedPath); - const dataUrl = nativeImg.toDataURL(); - // Validate data URL is substantial (not empty image) - if (dataUrl && dataUrl.length > 100) { - _startupIconCache[exePath] = dataUrl; - result[exePath] = dataUrl; - } else { - _startupIconCache[exePath] = null; - result[exePath] = null; - } - } catch (_) { - _startupIconCache[exePath] = null; - result[exePath] = null; - } - } - return result; - }); - - // Extract icons from executable paths for the processes page - const _processIconCache = {}; - ipcMain.handle('process:getIcons', async (_event, exePaths) => { - const unique = [...new Set((exePaths || []).filter(Boolean))]; - const result = {}; - for (const exePath of unique) { - if (exePath in _processIconCache) { - result[exePath] = _processIconCache[exePath]; - continue; - } - try { - const expandedPath = process.env.SystemRoot && exePath.includes('%SystemRoot%') - ? exePath.replace(/%SystemRoot%/gi, process.env.SystemRoot) - : exePath; - if (!fs.existsSync(expandedPath)) { - _processIconCache[exePath] = null; - result[exePath] = null; - continue; - } - const nativeImg = await app.getFileIcon(expandedPath); - const dataUrl = nativeImg.toDataURL(); - if (dataUrl && dataUrl.length > 100) { - _processIconCache[exePath] = dataUrl; - result[exePath] = dataUrl; - } else { - _processIconCache[exePath] = null; - result[exePath] = null; - } - } catch (_) { - _processIconCache[exePath] = null; - result[exePath] = null; - } - } - return result; - }); - - // Enable/disable a startup item - ipcMain.handle('startup:toggle', async (_event, item, enable) => { - try { - if (item.source === 'registry') { - const hive = item.scope === 'HKLM' ? 'HKLM' : 'HKCU'; - const key = `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`; - if (enable) { - execFileSync('reg', ['add', key, '/v', item.name, '/t', 'REG_SZ', '/d', item.command, '/f'], { timeout: 10000 }); - } else { - execFileSync('reg', ['delete', key, '/v', item.name, '/f'], { timeout: 10000 }); - } - return { ok: true }; - } else if (item.source === 'startup-folder') { - const appData = process.env.APPDATA || ''; - const programData = process.env.ProgramData || ''; - const userStartup = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); - const allStartup = path.join(programData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); - const startupDir = item.scope === 'user' ? userStartup : allStartup; - if (enable) { - const backup = path.join(startupDir, '.disabled', item.name); - if (fs.existsSync(backup)) { - fs.renameSync(backup, item.path); - return { ok: true }; - } - return { ok: false, error: 'No backup found to restore' }; - } else { - const disabledDir = path.join(startupDir, '.disabled'); - fs.mkdirSync(disabledDir, { recursive: true }); - const dest = path.join(disabledDir, item.name); - fs.renameSync(item.path, dest); - return { ok: true }; - } - } - return { ok: false, error: 'Toggle not supported for this item type' }; - } catch (err) { - return { ok: false, error: err.message }; - } + windowManager.dismissSplash( + windowManager.mainWindow, + windowManager.splashWindow, + windowManager.splashTimeoutId + ); }); - // Slow engine initialization (ClamAV definitions, real-time protection) - // runs in the background after the window is already visible, instead of - // blocking startup. scanEngine's scan handlers already check - // clamEngine.isReady and return a graceful error if a scan is attempted - // before this finishes, and rtp:status/rtp:toggle independently query - // live Defender state, so nothing depends on this completing first. - (async () => { + const pruneTimer = setInterval(() => { try { - await clamEngine.init(); + db.pruneNetworkStats(7); + db.pruneMaintenanceRuns(100); } catch (err) { - logLine('error', 'ClamAV init failed', { message: err.message }); + lifecycle.logLine('debug', 'Prune maintenance task failed', { error: err.message }); } - try { - if (featureFlags.getFlag(db, 'realtimeProtection', true)) { - await realtimeWatcher.start(); - } - } catch (err) { - logLine('error', 'Real-time protection init failed', { message: err.message }); - } - try { - if (featureFlags.getFlag(db, 'folderWatch', true)) { - folderWatcher.start(); - } - } catch (err) { - logLine('error', 'Folder watcher init failed', { message: err.message }); - } - try { - if (featureFlags.getFlag(db, 'networkAlerts', true)) { - networkAlertMonitor.start(); - } - } catch (err) { - logLine('error', 'Network alert monitor init failed', { message: err.message }); - } - try { - await blocklistService.refreshAll(); - } catch (err) { - logLine('error', 'Blocklist refresh failed', { message: err.message }); - } - if (featureFlags.getFlag(db, 'networkTrafficHistory', true)) { - services.startNetworkStatsTimer(); - } - const pruneTimer = setInterval(() => { - try { - db.pruneNetworkStats(7); - db.pruneMaintenanceRuns(100); - } catch (_) {} - }, 60 * 60_000); - if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); - lifecycleRefs.pruneTimer = pruneTimer; - try { - // systeminformation's networkStats() calculates rx_sec/tx_sec as a - // rate between two internal samples. The very first call anywhere in - // the process's lifetime has no prior sample to diff against and can - // return an empty/zeroed result. This throwaway call exists only to - // establish that baseline in the background, so the first time the - // user actually opens the Network Monitor page, the real call already - // has something to diff against and returns populated data immediately - // instead of requiring a second visit to "warm up". - await networkMonitor.getStats(); - } catch (err) { - logLine('error', 'Network stats warm-up failed', { message: err.message }); - } - })(); + }, 60 * 60_000); + if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); + services._pruneTimer = pruneTimer; }); process.on('uncaughtException', (err) => { - logLine('fatal', 'Uncaught exception', { message: err.message, stack: err.stack }); + lifecycle.logLine('fatal', 'Uncaught exception', { message: err.message, stack: err.stack }); }); process.on('unhandledRejection', (err) => { - logLine('fatal', 'Unhandled rejection', { message: err && err.message ? err.message : String(err), stack: err && err.stack }); + lifecycle.logLine('fatal', 'Unhandled rejection', { message: err && err.message ? err.message : String(err), stack: err && err.stack }); }); app.on('before-quit', () => { - isQuitting = true; - lifecycleRefs.maintenanceScheduler?.stop(); - lifecycleRefs.trayController?.dispose(); - if (lifecycleRefs.networkStatsTimer) clearInterval(lifecycleRefs.networkStatsTimer); - if (lifecycleRefs.pruneTimer) clearInterval(lifecycleRefs.pruneTimer); + if (windowManager.lifecycleRefs) { + windowManager.lifecycleRefs.maintenanceScheduler?.stop(); + windowManager.lifecycleRefs.trayController?.dispose(); + if (windowManager.lifecycleRefs.networkStatsTimer) clearInterval(windowManager.lifecycleRefs.networkStatsTimer); + if (windowManager.lifecycleRefs.pruneTimer) clearInterval(windowManager.lifecycleRefs.pruneTimer); + } try { - if (dbRef?.db && typeof dbRef.db.close === 'function') dbRef.db.close(); - } catch (_) {} + if (windowManager.dbRef?.db && typeof windowManager.dbRef.db.close === 'function') windowManager.dbRef.db.close(); + } catch (err) { + lifecycle.logLine('debug', 'Database close failed', { error: err.message }); + } }); app.on('window-all-closed', () => { - if (lifecycleRefs.trayController?.tray) return; + if (windowManager.lifecycleRefs?.trayController?.tray) return; if (process.platform !== 'darwin') app.quit(); -}); \ No newline at end of file +}); diff --git a/src/main/maintenanceScheduler.js b/src/main/maintenanceScheduler.js index b1da4b2..47d5f2b 100644 --- a/src/main/maintenanceScheduler.js +++ b/src/main/maintenanceScheduler.js @@ -1,5 +1,7 @@ 'use strict'; +const { log, ACTIONS } = require('../core/auditLog'); + const DEFAULT_MAINTENANCE = { enabled: false, schedulePreset: 'weekly', @@ -157,6 +159,8 @@ class MaintenanceScheduler { this.saveConfig({ lastAttempt: startedAt }); const results = []; + log(this.db, ACTIONS.MAINTENANCE_RUN, { scriptIds: config.scriptIds, dryRunCleanup }, { startedAt }, true); + try { for (const scriptId of config.scriptIds) { try { @@ -181,6 +185,13 @@ class MaintenanceScheduler { this.db.addMaintenanceRun({ startedAt, results, dryRunCleanup }); this.db.addAlert('info', `[Maintenance] ${summary} ${auditDetail}`); + // Prune stale incremental-scan cache entries (best-effort). + try { + this.db.pruneScannedFiles(30); + } catch (_) { + // Non-fatal: do not fail maintenance due to cache pruning errors. + } + if (okCount > 0) { this.saveConfig({ lastRun: startedAt }); } diff --git a/src/main/serviceRegistry.js b/src/main/serviceRegistry.js index 127200c..7e97c36 100644 --- a/src/main/serviceRegistry.js +++ b/src/main/serviceRegistry.js @@ -1,7 +1,11 @@ 'use strict'; const path = require('path'); -const ClamAVEngine = require('../security/ClamAVEngine'); +const ClamAVEngine = require(process.platform === 'darwin' + ? '../security/ClamAVEngine.macos' + : process.platform === 'linux' + ? '../security/ClamAVEngine.linux' + : '../security/ClamAVEngine'); const HeuristicEngine = require('../security/HeuristicEngine'); const ReputationEngine = require('../security/ReputationEngine'); const QuarantineManager = require('../security/QuarantineManager'); @@ -51,10 +55,10 @@ class ServiceRegistry { quarantineManager ); const realtimeWatcher = new RealTimeWatcher(db, eventBus, scanEngine); - const processInspector = new ProcessInspector(); + const processInspector = new ProcessInspector({ db }); const systemAudit = new SystemAudit(); systemAudit.setLocale(locale); - const firewallManager = new FirewallManager(); + const firewallManager = new FirewallManager(db); const networkMonitor = new NetworkMonitor(); const processResolver = new ProcessResolver(processInspector); const blocklistService = new BlocklistService(db); @@ -96,7 +100,8 @@ class ServiceRegistry { folderWatcher, networkAlertMonitor, emergencyLockdown, - toolRegistry + toolRegistry, + isActuallyAdmin: false }; return this._services; } diff --git a/src/main/trayDashboard.js b/src/main/trayDashboard.js index fd4b832..9efc17a 100644 --- a/src/main/trayDashboard.js +++ b/src/main/trayDashboard.js @@ -2,6 +2,7 @@ const { Tray, BrowserWindow, nativeImage, screen } = require('electron'); const path = require('path'); +const logger = require('../utils/logger'); const TRAY_WIDTH = 320; const TRAY_HEIGHT = 220; @@ -46,7 +47,9 @@ function initTrayDashboard({ app, mainWindow, getSummary }) { try { const summary = await getSummary(); trayWindow.webContents.send('tray:summary', summary); - } catch (_) {} + } catch (e) { + logger.debug?.('refreshTrayWindow failed', { error: e?.message || String(e) }); + } }; tray = new Tray(createTrayIcon()); diff --git a/src/main/updater.js b/src/main/updater.js index bb2f3fa..653d41d 100644 --- a/src/main/updater.js +++ b/src/main/updater.js @@ -23,7 +23,9 @@ const state = { function setState(patch) { Object.assign(state, patch); for (const listener of setState._listeners) { - try { listener({ ...state }); } catch (_) {} + try { listener({ ...state }); } catch (err) { + logger.debug('Updater listener threw', { error: err.message }); + } } } setState._listeners = new Set(); diff --git a/src/main/windowManager.js b/src/main/windowManager.js new file mode 100644 index 0000000..b374e93 --- /dev/null +++ b/src/main/windowManager.js @@ -0,0 +1,401 @@ +'use strict'; + +const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, screen } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const logger = require('../utils/logger'); +const { TOAST_THEMES, resolveThemeName, themeBackground } = require('../utils/themes'); +const i18n = require('../i18n'); +const { renderTemplate } = require('../utils/templates'); + +// Module-level state (shared across exported functions). +let dbRef = null; +let featureFlags = null; +let currentUiTheme = 'dark'; +let startupLocale = 'en'; +let logLine = (level, message, meta) => { const fn = logger[level] || logger.info; fn(message, meta || undefined); }; +let t = (key, vars) => i18n.t(key, i18n.normalizeLocale(startupLocale), vars); + +let mainWindow = null; +let splashWindow = null; +let splashTimeoutId = null; + +function init({ dbRef: db, featureFlags: ff, currentUiTheme: theme, startupLocale: locale, logLine: ll, t: translator }) { + dbRef = db; + featureFlags = ff; + currentUiTheme = theme; + startupLocale = locale; + logLine = ll || logLine; + t = translator || t; +} + +// Active toast windows stacked bottom-right. +const activeToasts = []; +const TOAST_WIDTH = 380; +const TOAST_HEIGHT = 180; +const TOAST_MARGIN = 16; +const TOAST_GAP = 10; +const TOAST_LIFETIME_MS = 6000; + +const TOAST_ICONS = { + info: '', + success: '', + warn: '', + danger: '', + threat: '' +}; + +function escToastHtml(v) { + return String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); +} + +function readPngAsDataUri(relativePath) { + try { + const fullPath = path.join(__dirname, '../../', relativePath); + const buf = fs.readFileSync(fullPath); + return `data:image/png;base64,${buf.toString('base64')}`; + } catch (_) { + return ''; + } +} + +function getToastMarkDataUri() { + if (!getToastMarkDataUri._cache) getToastMarkDataUri._cache = readPngAsDataUri('assets/toast-icon.png'); + return getToastMarkDataUri._cache; +} + +function getToastWordmarkDataUri() { + if (!getToastWordmarkDataUri._cache) getToastWordmarkDataUri._cache = readPngAsDataUri('assets/toast-wordmark.png'); + return getToastWordmarkDataUri._cache; +} + +function toastHtml(title, body, level, themeName, iconOverride = null) { + const theme = TOAST_THEMES[themeName] || TOAST_THEMES.dark; + const accent = theme.accents[level] || theme.accents.info; + const iconPaths = iconOverride || TOAST_ICONS[level] || TOAST_ICONS.info; + const markDataUri = getToastMarkDataUri(); + const wordmarkDataUri = getToastWordmarkDataUri(); + return renderTemplate(path.join(__dirname, '..', 'ui', 'templates', 'toast.html'), { + TOAST_WIDTH: TOAST_WIDTH, + THEME_BG: theme.bg, + THEME_BORDER: theme.border, + ACCENT: accent, + THEME_TEXT_MAIN: theme.textMain, + THEME_CLOSE_BTN: theme.closeBtn, + THEME_CLOSE_HOVER: theme.closeHover, + THEME_TEXT_MUTED: theme.textMuted, + MARK_DATA_URI: markDataUri ? `` : '', + WORDMARK_DATA_URI: wordmarkDataUri ? `` : 'Soterios', + ICON_PATHS: iconPaths, + TITLE: escToastHtml(title), + BODY: escToastHtml(body), + LIFETIME_MS: TOAST_LIFETIME_MS, + }); +} + +// Newest toast lands closest to the bottom margin; older ones already on +// screen get pushed upward above it, same stacking behavior as Windows' +// own Action Center toasts. +function repositionToasts() { + const display = screen.getPrimaryDisplay(); + const { x, y, width, height } = display.workArea; + let bottom = y + height - TOAST_MARGIN; + for (let i = activeToasts.length - 1; i >= 0; i--) { + const win = activeToasts[i]; + if (!win || win.isDestroyed()) continue; + const top = bottom - TOAST_HEIGHT; + win.setBounds({ x: x + width - TOAST_WIDTH - TOAST_MARGIN, y: top, width: TOAST_WIDTH, height: TOAST_HEIGHT }); + bottom = top - TOAST_GAP; + } +} + +function showNotification(title, body, level = 'info', iconOverride = null) { + if (dbRef && featureFlags && !featureFlags.getFlag(dbRef, 'notificationsEnabled', true)) return; + try { + const themeName = dbRef ? dbRef.getSetting('ui.theme', 'dark') : 'dark'; + const display = screen.getPrimaryDisplay(); + const { x, y, width, height } = display.workArea; + const toastWindow = new BrowserWindow({ + width: TOAST_WIDTH, + height: TOAST_HEIGHT, + x: x + width - TOAST_WIDTH - TOAST_MARGIN, + y: y + height - TOAST_HEIGHT - TOAST_MARGIN, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + focusable: false, + hasShadow: false, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: path.join(__dirname, '../preload/toastPreload.js') + } + }); + const translatedTitle = t(title); + const translatedBody = t(body); + toastWindow.setAlwaysOnTop(true, 'screen-saver'); + toastWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(toastHtml(translatedTitle, translatedBody, level, themeName, iconOverride))); + toastWindow.once('ready-to-show', () => toastWindow.show()); + toastWindow.on('closed', () => { + const idx = activeToasts.indexOf(toastWindow); + if (idx !== -1) activeToasts.splice(idx, 1); + repositionToasts(); + }); + activeToasts.push(toastWindow); + repositionToasts(); + } catch (err) { + logLine('debug', 'Toast creation failed', { error: err.message }); + } +} + +function createSplashWindow(themeName = 'dark') { + const theme = resolveThemeName(themeName); + splashWindow = new BrowserWindow({ + width: 660, + height: 440, + frame: false, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + show: false, + center: true, + skipTaskbar: true, + backgroundColor: themeBackground(theme), + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: path.join(__dirname, '../preload/splashPreload.js') + } + }); + splashWindow.loadFile(path.join(__dirname, '../ui/pages/splash.html'), { query: { theme } }); + splashWindow.once('ready-to-show', () => { + if (splashWindow && !splashWindow.isDestroyed()) splashWindow.show(); + }); + return splashWindow; +} + +function sendSplashProgress(splashWindow, pct, label) { + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.webContents.send('splash:progress', { pct, label }); + } +} + +function dismissSplash(mainWindowArg, splashWindowArg, splashTimeoutIdArg) { + const timeout = splashTimeoutIdArg ?? splashTimeoutId; + if (timeout) { + clearTimeout(timeout); + } + const main = mainWindowArg ?? mainWindow; + const splash = splashWindowArg ?? splashWindow; + if (main && !main.isDestroyed()) { + main.show(); + } + if (splash && !splash.isDestroyed()) { + splash.close(); + } +} + +function createIcon() { + const iconPath = path.join(__dirname, '../../assets/icon.ico'); + return nativeImage.createFromPath(iconPath); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 980, + minHeight: 640, + backgroundColor: themeBackground(currentUiTheme), + title: 'Soterios', + icon: createIcon(), + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, '../preload/preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true + }, + show: false + }); + + const shellHtmlPath = path.join(__dirname, '../ui/pages/shell.html'); + const screenshotConfig = getScreenshotConfig(); + if (isScreenshotCaptureMode() && !screenshotConfig) { + failScreenshotCapture('Screenshot capture requires --screenshot-page= and --screenshot-out='); + return { mainWindow, splashTimeoutId: null }; + } + if (screenshotConfig) { + mainWindow.loadFile(shellHtmlPath, { hash: screenshotConfig.page }); + scheduleScreenshotCapture(mainWindow, screenshotConfig); + } else { + mainWindow.loadFile(shellHtmlPath); + } + + splashTimeoutId = setTimeout(() => dismissSplash(mainWindow, splashWindow, splashTimeoutId), 8000); + + if ((process.argv.includes('--dev') || process.env.NODE_ENV === 'development') && !isScreenshotCaptureMode()) { + mainWindow.webContents.once('did-finish-load', () => { + mainWindow.webContents.openDevTools({ mode: 'detach' }); + }); + } + + return { mainWindow, splashTimeoutId }; +} + +function buildAppMenu(mainWindow) { + const isMac = process.platform === 'darwin'; + const aboutHandler = () => { + dialog.showMessageBox(mainWindow, { + type: 'info', + title: 'About Soterios', + message: 'Soterios', + detail: `Version ${app.getVersion()}\n\nLocal-first Windows security and maintenance platform.`, + buttons: ['OK'] + }); + }; + const template = [ + { label: 'File', submenu: [isMac ? { role: 'close' } : { role: 'quit' }] }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Help', + submenu: [ + { label: 'About Soterios', click: aboutHandler } + ] + } + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +function isScreenshotCaptureMode() { + return process.argv.includes('--screenshot-capture'); +} + +function getScreenshotConfig() { + if (!isScreenshotCaptureMode()) return null; + const pageArg = process.argv.find((arg) => arg.startsWith('--screenshot-page=')); + const outArg = process.argv.find((arg) => arg.startsWith('--screenshot-out=')); + if (!pageArg || !outArg) return null; + const page = pageArg.split('=').slice(1).join('='); + const outPath = outArg.split('=').slice(1).join('='); + if (!page || !outPath) return null; + return { page, outPath, runUninstaller: process.argv.includes('--screenshot-run-uninstaller') }; +} + +function failScreenshotCapture(message) { + logLine('error', message); + app.exit(1); +} + +function scheduleScreenshotCapture(win, config) { + win.webContents.once('did-finish-load', () => { + dismissSplash(win, null, null); + if (config.page === 'tools') win.setSize(1280, 980); + const delayMs = config.runUninstaller ? 2000 : 8000; + setTimeout(async () => { + try { + if (config.page === 'tools') { + await win.webContents.executeJavaScript(` + (async () => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + document.querySelector('[data-script-id="uninstaller-report"]')?.scrollIntoView({ block: 'center' }); + })(); + `); + await new Promise((resolve) => setTimeout(resolve, 800)); + } + if (config.runUninstaller) { + await win.webContents.executeJavaScript(` + (async () => { + let clicked = false; + for (let attempt = 0; attempt < 24; attempt += 1) { + const btn = document.querySelector('[data-script-id="uninstaller-report"]'); + if (btn && !btn.disabled) { + btn.scrollIntoView({ block: 'center' }); + btn.click(); + clicked = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!clicked) throw new Error('Uninstaller report button was not available'); + + for (let attempt = 0; attempt < 60; attempt += 1) { + const output = document.getElementById('toolOutput'); + const running = output && output.querySelector('.spinner'); + const hasContent = output && output.textContent && output.textContent.trim().length > 20; + if (!running && hasContent) { + output.scrollIntoView({ block: 'start' }); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error('Uninstaller report did not finish in time'); + })(); + `); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + const image = await win.webContents.capturePage(); + fs.mkdirSync(path.dirname(config.outPath), { recursive: true }); + fs.writeFileSync(config.outPath, image.toPNG()); + logLine('info', 'Screenshot saved', { path: config.outPath }); + app.quit(); + } catch (err) { + logLine('error', 'Screenshot capture failed', { error: err.message }); + process.exitCode = 1; + app.quit(); + } + }, delayMs); + }); +} + +module.exports = { + init, + createSplashWindow, + sendSplashProgress, + dismissSplash, + createWindow, + buildAppMenu, + showNotification, + repositionToasts, + toastHtml, + escToastHtml, + getToastMarkDataUri, + getToastWordmarkDataUri, + readPngAsDataUri, + scheduleScreenshotCapture, + failScreenshotCapture, + getScreenshotConfig, + isScreenshotCaptureMode, + createIcon, + activeToasts, + TOAST_WIDTH, + TOAST_HEIGHT, + TOAST_MARGIN, + TOAST_GAP, + TOAST_LIFETIME_MS, + TOAST_ICONS, +}; diff --git a/src/preload/preload.js b/src/preload/preload.js index 2abcf81..b52fc6f 100644 --- a/src/preload/preload.js +++ b/src/preload/preload.js @@ -1,8 +1,115 @@ const { contextBridge, ipcRenderer } = require('electron'); +// Explicit allowlists so the renderer cannot invoke arbitrary main-process +// handlers or register listeners on unapproved channels. Any channel not +// listed here is rejected at the preload boundary. + +const ALLOWED_INVOKE = new Set([ + // Scanner + 'scan:status', 'scan:quick', 'scan:full', 'scan:abort', + 'scan:updateDefinitions', + // Reputation + 'reputation:addHash', 'reputation:removeHash', + 'reputation:listHashes', 'reputation:checkHash', + // Schedule + 'schedule:get', 'schedule:set', 'schedule:getCustomPaths', + // Tools + 'tools:list', 'tools:run', + // Dialogs + 'dialog:pickFolder', 'dialog:pickFiles', + // Shell + 'shell:showItemInFolder', 'shell:openPath', + // App + 'app:info', 'app:getLaunchAtStartup', 'app:setLaunchAtStartup', + 'app:ready', 'app:exportSettings', + // Startup + 'startup:getIcons', 'startup:toggle', + // Process + 'process:list', 'process:kill', 'process:getIcons', + // Firewall + 'firewall:status', 'firewall:rules', 'firewall:listRules', + 'firewall:createRule', 'firewall:deleteRule', + 'firewall:setRuleEnabled', 'firewall:setProfileEnabled', + 'firewall:exportRules', 'firewall:importRules', 'firewall:getTrusted', + // Network + 'network:connections', 'network:stats', 'network:history', + 'network:geo', 'network:measureBandwidth', + 'network-alerts:status', 'network-alerts:toggle', + 'network-alerts:ignore', 'network-alerts:kill', + 'network-traffic-history:toggle', + 'network:userBlocklist:list', 'network:userBlocklist:add', + 'network:userBlocklist:remove', 'network:userBlocklist:clear', + 'network:domainBlocklist:list', 'network:domainBlocklist:add', + 'network:domainBlocklist:remove', 'network:domainBlocklist:clear', + // Database / Settings + 'db:getScanHistory', 'db:getQuarantineList', + 'db:getUnreadAlerts', 'db:markAlertRead', + 'db:getSetting', 'db:setSetting', + // Warnings + 'warnings:ignore', 'warnings:unignore', 'warnings:listIgnored', + // Alerts + 'alerts:list', 'alerts:counts', + // Audit + 'audit:run', 'audit:log', + // Maintenance + 'maintenance:get', 'maintenance:set', 'maintenance:getScripts', + 'maintenance:getHistory', 'maintenance:runNow', + // Updates + 'update:check', 'update:status', 'update:install', + // Tray + 'tray:getSummary', 'tray:openMain', 'tray:quit', + // Reports + 'reports:list', 'scanReports:list', 'scanReports:latest', + 'scanReports:delete', 'report:exportPDF', 'report:exportCSV', + 'reports:delete', 'reports:read', + // Quarantine + 'quarantine:restore', 'quarantine:delete', + // Lockdown + 'lockdown:getStatus', 'lockdown:activate', 'lockdown:restore', + 'lockdown:getAllowlist', 'lockdown:setAllowlist', + 'lockdown:addToAllowlist', 'lockdown:removeFromAllowlist', + // External lookups + 'hibp:password', 'xon:email', + // Health + 'health:score', + // i18n + 'i18n:getCatalog', 'i18n:normalizeLocale', 'i18n:listLocales', + 'i18n:isRtlLocale', 'i18n:getSystemLocale', + // Browser extension + 'browserExtension:installNativeHost', + 'credential-leak:notify', +]); + +const ALLOWED_ON = new Set([ + // Scan progress + 'scan:progress', 'scan:complete', 'scan:canceled', + // Updates + 'update:status', + // Network + 'network:connections:progress', + // Audit + 'audit:progress', + // Splash + 'splash:progress', + // Folder watch + 'folderwatch:threat', + // Lockdown + 'lockdown:changed', + // Navigation + 'navigate-to-scanner', +]); + contextBridge.exposeInMainWorld('api', { - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + invoke: (channel, ...args) => { + if (!ALLOWED_INVOKE.has(channel)) { + throw new Error(`IPC channel not allowed: ${channel}`); + } + return ipcRenderer.invoke(channel, ...args); + }, on: (channel, callback) => { + if (!ALLOWED_ON.has(channel)) { + throw new Error(`IPC listener channel not allowed: ${channel}`); + } const listener = (event, ...args) => callback(...args); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); diff --git a/src/preload/toastPreload.js b/src/preload/toastPreload.js new file mode 100644 index 0000000..cadc124 --- /dev/null +++ b/src/preload/toastPreload.js @@ -0,0 +1,5 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('toastApi', { + navigateToScanner: () => ipcRenderer.send('toast:navigate-scanner') +}); diff --git a/src/scripts/childRunner.js b/src/scripts/childRunner.js index 6420591..7f84e1f 100644 --- a/src/scripts/childRunner.js +++ b/src/scripts/childRunner.js @@ -19,7 +19,7 @@ // progress) simply ignore the extra argument, which is safe in JS. const onProgress = (payload) => { if (process && process.send) { - try { process.send({ type: 'progress', payload }); } catch (_) {} + try { process.send({ type: 'progress', payload }); } catch (e) { console.debug?.('childRunner progress send failed', { error: e?.message || String(e) }); } } }; diff --git a/src/scripts/safeScripts/browserCacheReport.js b/src/scripts/safeScripts/browserCacheReport.js index 7762e72..66d0b71 100644 --- a/src/scripts/safeScripts/browserCacheReport.js +++ b/src/scripts/safeScripts/browserCacheReport.js @@ -9,7 +9,7 @@ function dirSize(dirPath) { try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (err) { return; } for (const entry of entries) { const fullPath = path.join(current, entry.name); - try { if (entry.isDirectory()) walk(fullPath); else if (entry.isFile()) total += fs.statSync(fullPath).size; } catch (err) {} + try { if (entry.isDirectory()) walk(fullPath); else if (entry.isFile()) total += fs.statSync(fullPath).size; } catch (err) { console.debug?.('browserCacheReport walk failed', { path: fullPath, error: err?.message || String(err) }); } } } if (fs.existsSync(dirPath)) walk(dirPath); diff --git a/src/scripts/safeScripts/largeFilesReport.js b/src/scripts/safeScripts/largeFilesReport.js index 38b0486..3606e86 100644 --- a/src/scripts/safeScripts/largeFilesReport.js +++ b/src/scripts/safeScripts/largeFilesReport.js @@ -36,7 +36,7 @@ module.exports = async function largeFilesReport(args = {}, onProgress) { if (onProgress && scannedCount % REPORT_EVERY === 0) { onProgress({ label: 'Scanning files', count: scannedCount }); } - try { const stat = fs.statSync(fullPath); if (stat.size >= minBytes) files.push({ path: fullPath, sizeMB: +(stat.size / 1024 / 1024).toFixed(1), modifiedAt: stat.mtime.toISOString() }); } catch (err) {} + try { const stat = fs.statSync(fullPath); if (stat.size >= minBytes) files.push({ path: fullPath, sizeMB: +(stat.size / 1024 / 1024).toFixed(1), modifiedAt: stat.mtime.toISOString() }); } catch (err) { console.debug?.('largeFilesReport stat failed', { path: fullPath, error: err?.message || String(err) }); } } } walk(root, 0); diff --git a/src/scripts/safeScripts/listStartupItems.js b/src/scripts/safeScripts/listStartupItems.js index 6813a19..dbc716c 100644 --- a/src/scripts/safeScripts/listStartupItems.js +++ b/src/scripts/safeScripts/listStartupItems.js @@ -60,9 +60,9 @@ module.exports = async function listStartupItems() { if (fs.existsSync(dir)) { fs.readdirSync(dir).forEach(file => items.push({ name: file, path: path.join(dir, file), scope, source: 'startup-folder' })); } - } catch (_) { } + } catch (e) { console.debug?.('listStartupItems folder read failed', { dir, error: e?.message || String(e) }); } }); - } catch (_) { } + } catch (e) { console.debug?.('listStartupItems outer failed', { error: e?.message || String(e) }); } // Fallback: wmic if nothing found if (items.length === 0) { diff --git a/src/scripts/safeScripts/uninstallLaunchUtils.js b/src/scripts/safeScripts/uninstallLaunchUtils.js index 0b294f2..c33c436 100644 --- a/src/scripts/safeScripts/uninstallLaunchUtils.js +++ b/src/scripts/safeScripts/uninstallLaunchUtils.js @@ -41,7 +41,7 @@ function parseUninstallCommand(uninstallString) { if (/\.(exe|msi|bat|cmd)$/i.test(candidate)) break; try { if (fs.existsSync(candidate)) break; - } catch (_) {} + } catch (e) { console.debug?.('uninstallLaunchUtils existsSync failed', { candidate, error: e?.message || String(e) }); } } return { diff --git a/src/security/BlocklistService.js b/src/security/BlocklistService.js index f8116e2..2b71526 100644 --- a/src/security/BlocklistService.js +++ b/src/security/BlocklistService.js @@ -46,7 +46,9 @@ class BlocklistService { if (cached && cached.raw_data) { this.parseAndStore(source.name, cached.raw_data, source.version); } - } catch (_) {} + } catch (e) { + logger.debug('Blocklist cache parse failed', { source: source?.name, error: e?.message || String(e) }); + } } } @@ -173,15 +175,24 @@ class BlocklistService { } async fetchBlocklist(source) { + const MAX_BLOCKLIST_BODY_BYTES = 10 * 1024 * 1024; // 10 MB return new Promise((resolve, reject) => { const req = https.get(source.url, { headers: { 'User-Agent': 'Soterios' } }, (res) => { let data = ''; - res.on('data', chunk => { data += chunk; }); + res.on('data', chunk => { + data += chunk; + if (Buffer.byteLength(data) > MAX_BLOCKLIST_BODY_BYTES) { + req.destroy(new Error('Blocklist response exceeds size limit')); + reject(new Error('Blocklist response too large')); + } + }); res.on('end', () => { - if (res.statusCode === 200) resolve(data); - else reject(new Error(`HTTP ${res.statusCode}`)); + if (!req.destroyed) { + if (res.statusCode === 200) resolve(data); + else reject(new Error(`HTTP ${res.statusCode}`)); + } }); }); diff --git a/src/security/ClamAVEngine.js b/src/security/ClamAVEngine.js index 32ddcb8..c16893a 100644 --- a/src/security/ClamAVEngine.js +++ b/src/security/ClamAVEngine.js @@ -1,329 +1,21 @@ -const logger = require('../utils/logger'); -const { spawn } = require('child_process'); const path = require('path'); -const fs = require('fs'); +const ClamAVEngineBase = require('./ClamAVEngineBase'); -class ClamAVEngine { +class ClamAVEngine extends ClamAVEngineBase { constructor(options = {}) { - const candidates = [ - options.baseDir, - process.resourcesPath ? path.join(process.resourcesPath, 'assets', 'clamav') : null, - process.resourcesPath ? path.join(process.resourcesPath, 'app.asar.unpacked', 'assets', 'clamav') : null, - path.join(__dirname, '..', '..', 'assets', 'clamav') - ].filter(Boolean); - - this.baseDir = candidates.find(dir => fs.existsSync(path.join(dir, 'clamscan.exe'))) || candidates[candidates.length - 1]; - this.clamscanPath = path.join(this.baseDir, 'clamscan.exe'); - this.freshclamPath = path.join(this.baseDir, 'freshclam.exe'); - this.certsDir = path.join(this.baseDir, 'certs'); - this.dbDir = options.dbDir || path.join(this.baseDir, 'database'); - this.isReady = false; - this.lastUpdateError = null; - this.activeScanProcess = null; - this.activeUpdateProcess = null; - this.cancelScanRequested = false; - this.cancelUpdateRequested = false; - } - - async init() { - if (!fs.existsSync(this.clamscanPath)) { - logger.warn('ClamAV executable not found at ' + this.clamscanPath); - this.isReady = false; - return; - } - - fs.mkdirSync(this.dbDir, { recursive: true }); - - if (!this.hasVirusDatabase()) { - logger.warn('ClamAV virus definitions not found in ' + this.dbDir + '; downloading with freshclam.'); - const updateResult = await this.updateDefinitions(); - if (!updateResult.success) { - this.lastUpdateError = updateResult.error || updateResult.output || 'Unable to update ClamAV definitions'; - logger.warn('ClamAV definition update failed: ' + this.lastUpdateError); - } - } - - this.isReady = true; - logger.info('ClamAV engine initialized at ' + this.baseDir); - } - - getStatus() { - return { - ready: this.isReady, - hasDefinitions: this.hasVirusDatabase(), - baseDir: this.baseDir, - dbDir: this.dbDir, - lastUpdateError: this.lastUpdateError - }; - } - - hasVirusDatabase() { - const dbFiles = [ - 'main.cvd', - 'daily.cvd', - 'bytecode.cvd', - 'main.cld', - 'daily.cld', - 'bytecode.cld' - ]; - if (dbFiles.some(file => fs.existsSync(path.join(this.dbDir, file)))) return true; - - try { - return fs.readdirSync(this.dbDir).some(file => /\.(hdb|hsb|ndb|ldb|yara|yar)$/i.test(file)); - } catch (_) { - return false; - } - } - - updateDefinitions(onProgress) { - if (!fs.existsSync(this.freshclamPath)) { - return Promise.resolve({ success: false, error: 'freshclam.exe not found at ' + this.freshclamPath, output: '' }); - } - - fs.mkdirSync(this.dbDir, { recursive: true }); - const configPath = this.ensureFreshclamConfig(); - - return new Promise((resolve) => { - const args = [ - '--config-file=' + configPath, - '--stdout', - '--show-progress', - '--datadir=' + this.dbDir - ]; - - if (fs.existsSync(this.certsDir)) { - args.push('--cvdcertsdir=' + this.certsDir); - } - - let output = ''; - let freshclam; - try { - freshclam = spawn(this.freshclamPath, args, { - cwd: this.baseDir, - windowsHide: true - }); - this.activeUpdateProcess = freshclam; - } catch (err) { - resolve({ success: false, error: err.message, output }); - return; - } - - const finish = (result) => { - if (this.activeUpdateProcess === freshclam) this.activeUpdateProcess = null; - resolve(result); - }; - - const handleData = (data) => { - const chunk = data.toString(); - output += chunk; - if (onProgress) onProgress({ phase: 'update', text: chunk }); - }; - - freshclam.stdout.on('data', handleData); - freshclam.stderr.on('data', handleData); - - freshclam.on('close', (code) => { - const wasCanceled = this.cancelUpdateRequested; - if (this.activeUpdateProcess === freshclam) { - this.activeUpdateProcess = null; - this.cancelUpdateRequested = false; - } - - if (wasCanceled) { - finish({ success: false, canceled: true, error: 'Definition update canceled', output }); - return; - } - - const hasDb = this.hasVirusDatabase(); - if (code === 0 || hasDb) { - this.lastUpdateError = null; - finish({ success: true, code, output }); - return; - } - - const error = output.trim() || 'freshclam exited with code ' + code; - finish({ success: false, code, output, error }); - }); - - freshclam.on('error', (err) => { - finish({ success: false, error: err.message, output }); - }); - }); - } - - ensureFreshclamConfig() { - const configPath = path.join(this.dbDir, 'freshclam.conf'); - const lines = [ - 'DatabaseDirectory "' + this.toClamPath(this.dbDir) + '"', - 'DatabaseMirror database.clamav.net', - 'ScriptedUpdates yes', - 'LogTime yes', - 'UpdateLogFile "' + this.toClamPath(path.join(this.dbDir, 'freshclam.log')) + '"', - 'ConnectTimeout 30', - 'ReceiveTimeout 60' - ]; - - if (fs.existsSync(this.certsDir)) { - lines.push('CVDCertsDirectory "' + this.toClamPath(this.certsDir) + '"'); - } - - fs.writeFileSync(configPath, lines.join('\n') + '\n', 'utf8'); - return configPath; + super(options); } - toClamPath(value) { - return path.resolve(value).replace(/\\/g, '/'); + _clamscanPath(baseDir) { + return baseDir ? path.join(baseDir, 'clamscan.exe') : ''; } - async scanFile(filePath, onProgress) { - if (!this.isReady) { - return { success: false, error: 'ClamAV not ready', threatsFound: 0, filesScanned: 0, output: '' }; - } - - if (!this.hasVirusDatabase()) { - const updateResult = await this.updateDefinitions(onProgress); - if (!updateResult.success || !this.hasVirusDatabase()) { - return { - success: false, - error: 'ClamAV virus definitions are not available. ' + (updateResult.error || this.lastUpdateError || ''), - threatsFound: 0, - filesScanned: 0, - output: updateResult.output || '' - }; - } - } - - let isDir; - try { - isDir = fs.statSync(filePath).isDirectory(); - } catch (err) { - return { success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }; - } - - return new Promise((resolve) => { - const args = [ - '--stdout', - '--database=' + this.toClamPath(this.dbDir), - '--max-dir-recursion=32' - ]; - - if (isDir) { - args.push('--recursive'); - } - - args.push(filePath); - - let clam; - try { - clam = spawn(this.clamscanPath, args, { - cwd: this.baseDir, - windowsHide: true - }); - this.activeScanProcess = clam; - } catch (err) { - resolve({ success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }); - return; - } - let output = ''; - let stderr = ''; - let lines = []; - - const finish = (result) => { - if (this.activeScanProcess === clam) this.activeScanProcess = null; - resolve(result); - }; - - const handleOutput = (data) => { - const chunk = data.toString(); - output += chunk; - lines = lines.concat(chunk.split(/\r?\n/).filter(line => line.trim())); - - if (onProgress) { - const fileLines = lines.filter(line => /: (OK|.+ FOUND|ERROR)$/i.test(line.trim())); - onProgress({ text: chunk, fileCount: fileLines.length }); - } - }; - - clam.stdout.on('data', handleOutput); - clam.stderr.on('data', (data) => { - stderr += data.toString(); - handleOutput(data); - }); - - clam.on('close', (code) => { - const wasCanceled = this.cancelScanRequested; - if (this.activeScanProcess === clam) { - this.activeScanProcess = null; - this.cancelScanRequested = false; - } - - if (wasCanceled) { - finish({ - success: false, - canceled: true, - error: 'Scan canceled', - threats: [], - threatsFound: 0, - output, - filesScanned: 0 - }); - return; - } - - const fileLines = lines.filter(line => /: (OK|.+ FOUND|ERROR)$/i.test(line.trim())); - const foundLines = lines.filter(line => /: .+ FOUND$/i.test(line.trim())); - const accessDeniedLines = lines.filter(line => - /: (can't open file|lstat\(\) failed|permission denied|access is denied)/i.test(line) - ); - const realErrorLines = lines.filter(line => - /: ERROR$/i.test(line.trim()) && !/can't open file|lstat\(\) failed|permission denied|access is denied/i.test(line) - ); - const threats = foundLines.map(line => { - const match = line.match(/^(.*):\s+(.+)\s+FOUND$/i); - return match ? { path: match[1], name: match[2] } : { path: line, name: 'Unknown' }; - }); - - const onlyOpenErrors = code === 2 && accessDeniedLines.length > 0 && foundLines.length === 0 && realErrorLines.length === 0; - const error = code === 2 && !onlyOpenErrors ? (stderr || output).trim() || 'clamscan exited with code 2' : null; - - finish({ - success: code !== 2 || onlyOpenErrors, - error, - warnings: accessDeniedLines, - note: onlyOpenErrors ? `${accessDeniedLines.length} protected file(s) could not be opened and were skipped.` : null, - threats, - threatsFound: threats.length, - output, - filesScanned: fileLines.length - }); - }); - - clam.on('error', (err) => { - finish({ success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }); - }); - }); + _freshclamPath(baseDir) { + return baseDir ? path.join(baseDir, 'freshclam.exe') : ''; } - abortCurrentScan() { - let killed = false; - - if (this.activeScanProcess) { - this.cancelScanRequested = true; - try { - this.activeScanProcess.kill(); - killed = true; - } catch (_) {} - } - - if (this.activeUpdateProcess) { - this.cancelUpdateRequested = true; - try { - this.activeUpdateProcess.kill(); - killed = true; - } catch (_) {} - } - - return killed; + _spawnOptions() { + return { windowsHide: true }; } } diff --git a/src/security/ClamAVEngine.linux.js b/src/security/ClamAVEngine.linux.js new file mode 100644 index 0000000..0097b2e --- /dev/null +++ b/src/security/ClamAVEngine.linux.js @@ -0,0 +1,25 @@ +const path = require('path'); +const ClamAVEngineBase = require('./ClamAVEngineBase'); + +class ClamAVEngineLinux extends ClamAVEngineBase { + constructor(options = {}) { + super(options); + } + + _clamscanPath(baseDir) { + // On Linux, clamscan is typically in /usr/bin/clamscan or bundled. + const bundled = baseDir ? path.join(baseDir, 'clamscan') : ''; + return bundled || '/usr/bin/clamscan'; + } + + _freshclamPath(baseDir) { + const bundled = baseDir ? path.join(baseDir, 'freshclam') : ''; + return bundled || '/usr/bin/freshclam'; + } + + _spawnOptions() { + return {}; + } +} + +module.exports = ClamAVEngineLinux; diff --git a/src/security/ClamAVEngine.macos.js b/src/security/ClamAVEngine.macos.js new file mode 100644 index 0000000..941e3b2 --- /dev/null +++ b/src/security/ClamAVEngine.macos.js @@ -0,0 +1,25 @@ +const path = require('path'); +const ClamAVEngineBase = require('./ClamAVEngineBase'); + +class ClamAVEngineMacOS extends ClamAVEngineBase { + constructor(options = {}) { + super(options); + } + + _clamscanPath(baseDir) { + // On macOS, clamscan is typically in /usr/local/bin/clamscan or bundled. + const bundled = baseDir ? path.join(baseDir, 'clamscan') : ''; + return bundled || '/usr/local/bin/clamscan'; + } + + _freshclamPath(baseDir) { + const bundled = baseDir ? path.join(baseDir, 'freshclam') : ''; + return bundled || '/usr/local/bin/freshclam'; + } + + _spawnOptions() { + return {}; + } +} + +module.exports = ClamAVEngineMacOS; diff --git a/src/security/ClamAVEngineBase.js b/src/security/ClamAVEngineBase.js new file mode 100644 index 0000000..8b4818b --- /dev/null +++ b/src/security/ClamAVEngineBase.js @@ -0,0 +1,336 @@ +const logger = require('../utils/logger'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +class ClamAVEngineBase { + constructor(options = {}) { + const candidates = [ + options.baseDir, + process.resourcesPath ? path.join(process.resourcesPath, 'assets', 'clamav') : null, + process.resourcesPath ? path.join(process.resourcesPath, 'app.asar.unpacked', 'assets', 'clamav') : null, + path.join(__dirname, '..', '..', 'assets', 'clamav') + ].filter(Boolean); + + this.baseDir = candidates.find(dir => fs.existsSync(this._clamscanPath(dir))) || candidates[candidates.length - 1]; + this.clamscanPath = this._clamscanPath(this.baseDir); + this.freshclamPath = this._freshclamPath(this.baseDir); + this.certsDir = path.join(this.baseDir, 'certs'); + this.dbDir = options.dbDir || path.join(this.baseDir, 'database'); + this.isReady = false; + this.lastUpdateError = null; + this.activeScanProcess = null; + this.activeUpdateProcess = null; + this.cancelScanRequested = false; + this.cancelUpdateRequested = false; + } + + _clamscanPath(baseDir) { return path.join(baseDir, 'clamscan'); } + _freshclamPath(baseDir) { return path.join(baseDir, 'freshclam'); } + _spawnArgs(command, filePath, isDir) { + const args = [ + '--stdout', + '--database=' + this.toClamPath(this.dbDir), + '--max-dir-recursion=32' + ]; + if (isDir) args.push('--recursive'); + args.push(filePath); + return args; + } + _spawnOptions() { + return { windowsHide: true }; + } + + async init() { + if (!fs.existsSync(this.clamscanPath)) { + logger.warn('ClamAV executable not found at ' + this.clamscanPath); + this.isReady = false; + return; + } + + fs.mkdirSync(this.dbDir, { recursive: true }); + + if (!this.hasVirusDatabase()) { + logger.warn('ClamAV virus definitions not found in ' + this.dbDir + '; downloading with freshclam.'); + const updateResult = await this.updateDefinitions(); + if (!updateResult.success) { + this.lastUpdateError = updateResult.error || updateResult.output || 'Unable to update ClamAV definitions'; + logger.warn('ClamAV definition update failed: ' + this.lastUpdateError); + } + } + + this.isReady = true; + logger.info('ClamAV engine initialized at ' + this.baseDir); + } + + getStatus() { + return { + ready: this.isReady, + hasDefinitions: this.hasVirusDatabase(), + baseDir: this.baseDir, + dbDir: this.dbDir, + lastUpdateError: this.lastUpdateError + }; + } + + hasVirusDatabase() { + const dbFiles = [ + 'main.cvd', 'daily.cvd', 'bytecode.cvd', + 'main.cld', 'daily.cld', 'bytecode.cld' + ]; + if (dbFiles.some(file => fs.existsSync(path.join(this.dbDir, file)))) return true; + + try { + return fs.readdirSync(this.dbDir).some(file => /\.(hdb|hsb|ndb|ldb|yara|yar)$/i.test(file)); + } catch (_) { + return false; + } + } + + updateDefinitions(onProgress) { + if (!fs.existsSync(this.freshclamPath)) { + return Promise.resolve({ success: false, error: this.freshclamPath + ' not found', output: '' }); + } + + fs.mkdirSync(this.dbDir, { recursive: true }); + const configPath = this.ensureFreshclamConfig(); + + return new Promise((resolve) => { + const args = [ + '--config-file=' + configPath, + '--stdout', + '--show-progress', + '--datadir=' + this.dbDir + ]; + + if (fs.existsSync(this.certsDir)) { + args.push('--cvdcertsdir=' + this.certsDir); + } + + let output = ''; + let freshclam; + try { + freshclam = spawn(this.freshclamPath, args, { + cwd: this.baseDir, + ...this._spawnOptions() + }); + this.activeUpdateProcess = freshclam; + } catch (err) { + resolve({ success: false, error: err.message, output }); + return; + } + + const finish = (result) => { + if (this.activeUpdateProcess === freshclam) this.activeUpdateProcess = null; + resolve(result); + }; + + const handleData = (data) => { + const chunk = data.toString(); + output += chunk; + if (onProgress) onProgress({ phase: 'update', text: chunk }); + }; + + freshclam.stdout.on('data', handleData); + freshclam.stderr.on('data', handleData); + + freshclam.on('close', (code) => { + const wasCanceled = this.cancelUpdateRequested; + if (this.activeUpdateProcess === freshclam) { + this.activeUpdateProcess = null; + this.cancelUpdateRequested = false; + } + + if (wasCanceled) { + finish({ success: false, canceled: true, error: 'Definition update canceled', output }); + return; + } + + const hasDb = this.hasVirusDatabase(); + if (code === 0 || hasDb) { + this.lastUpdateError = null; + finish({ success: true, code, output }); + return; + } + + const error = output.trim() || 'freshclam exited with code ' + code; + finish({ success: false, code, output, error }); + }); + + freshclam.on('error', (err) => { + finish({ success: false, error: err.message, output }); + }); + }); + } + + ensureFreshclamConfig() { + const configPath = path.join(this.dbDir, 'freshclam.conf'); + const lines = [ + 'DatabaseDirectory "' + this.toClamPath(this.dbDir) + '"', + 'DatabaseMirror database.clamav.net', + 'ScriptedUpdates yes', + 'LogTime yes', + 'UpdateLogFile "' + this.toClamPath(path.join(this.dbDir, 'freshclam.log')) + '"', + 'ConnectTimeout 30', + 'ReceiveTimeout 60' + ]; + + if (fs.existsSync(this.certsDir)) { + lines.push('CVDCertsDirectory "' + this.toClamPath(this.certsDir) + '"'); + } + + fs.writeFileSync(configPath, lines.join('\n') + '\n', 'utf8'); + return configPath; + } + + toClamPath(value) { + return path.resolve(value).replace(/\\/g, '/'); + } + + async scanFile(filePath, onProgress) { + if (!this.isReady) { + return { success: false, error: 'ClamAV not ready', threatsFound: 0, filesScanned: 0, output: '' }; + } + + if (!this.hasVirusDatabase()) { + const updateResult = await this.updateDefinitions(onProgress); + if (!updateResult.success || !this.hasVirusDatabase()) { + return { + success: false, + error: 'ClamAV virus definitions are not available. ' + (updateResult.error || this.lastUpdateError || ''), + threatsFound: 0, + filesScanned: 0, + output: updateResult.output || '' + }; + } + } + + let isDir; + try { + isDir = fs.statSync(filePath).isDirectory(); + } catch (err) { + return { success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }; + } + + return new Promise((resolve) => { + const args = this._spawnArgs(this.clamscanPath, filePath, isDir); + + let clam; + try { + clam = spawn(this.clamscanPath, args, { + cwd: this.baseDir, + ...this._spawnOptions() + }); + this.activeScanProcess = clam; + } catch (err) { + resolve({ success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }); + return; + } + let output = ''; + let stderr = ''; + let lines = []; + + const finish = (result) => { + if (this.activeScanProcess === clam) this.activeScanProcess = null; + resolve(result); + }; + + const handleOutput = (data) => { + const chunk = data.toString(); + output += chunk; + lines = lines.concat(chunk.split(/\r?\n/).filter(line => line.trim())); + + if (onProgress) { + const fileLines = lines.filter(line => /: (OK|.+ FOUND|ERROR)$/i.test(line.trim())); + onProgress({ text: chunk, fileCount: fileLines.length }); + } + }; + + clam.stdout.on('data', handleOutput); + clam.stderr.on('data', (data) => { + stderr += data.toString(); + handleOutput(data); + }); + + clam.on('close', (code) => { + const wasCanceled = this.cancelScanRequested; + if (this.activeScanProcess === clam) { + this.activeScanProcess = null; + this.cancelScanRequested = false; + } + + if (wasCanceled) { + finish({ + success: false, + canceled: true, + error: 'Scan canceled', + threats: [], + threatsFound: 0, + output, + filesScanned: 0 + }); + return; + } + + const fileLines = lines.filter(line => /: (OK|.+ FOUND|ERROR)$/i.test(line.trim())); + const foundLines = lines.filter(line => /: .+ FOUND$/i.test(line.trim())); + const accessDeniedLines = lines.filter(line => + /: (can't open file|lstat\(\) failed|permission denied|access is denied)/i.test(line) + ); + const realErrorLines = lines.filter(line => + /: ERROR$/i.test(line.trim()) && !/can't open file|lstat\(\) failed|permission denied|access is denied/i.test(line) + ); + const threats = foundLines.map(line => { + const match = line.match(/^(.*):\s+(.+)\s+FOUND$/i); + return match ? { path: match[1], name: match[2] } : { path: line, name: 'Unknown' }; + }); + + const onlyOpenErrors = code === 2 && accessDeniedLines.length > 0 && foundLines.length === 0 && realErrorLines.length === 0; + const error = code === 2 && !onlyOpenErrors ? (stderr || output).trim() || 'clamscan exited with code 2' : null; + + finish({ + success: code !== 2 || onlyOpenErrors, + error, + warnings: accessDeniedLines, + note: onlyOpenErrors ? `${accessDeniedLines.length} protected file(s) could not be opened and were skipped.` : null, + threats, + threatsFound: threats.length, + output, + filesScanned: fileLines.length + }); + }); + + clam.on('error', (err) => { + finish({ success: false, error: err.message, threatsFound: 0, filesScanned: 0, output: '' }); + }); + }); + } + + abortCurrentScan() { + let killed = false; + + if (this.activeScanProcess) { + this.cancelScanRequested = true; + try { + this.activeScanProcess.kill(); + killed = true; + } catch (e) { + logger.debug('activeScanProcess.kill failed', { error: e?.message || String(e) }); + } + } + + if (this.activeUpdateProcess) { + this.cancelUpdateRequested = true; + try { + this.activeUpdateProcess.kill(); + killed = true; + } catch (e) { + logger.debug('activeUpdateProcess.kill failed', { error: e?.message || String(e) }); + } + } + + return killed; + } +} + +module.exports = ClamAVEngineBase; diff --git a/src/security/EmergencyLockdown.js b/src/security/EmergencyLockdown.js index 0b776c8..da1dfdb 100644 --- a/src/security/EmergencyLockdown.js +++ b/src/security/EmergencyLockdown.js @@ -3,6 +3,10 @@ const { execFileSync } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(require('child_process').exec); +const { InvalidInputError, AppError } = require('../utils/errors'); +const { log, ACTIONS } = require('../core/auditLog'); + +const SAFE_INTERFACE_NAME = /^[^\s'"\\|&;<>]+$/; /** * Emergency Lockdown Service @@ -102,7 +106,7 @@ class EmergencyLockdown { } return interfaces; } catch (err) { - throw new Error(`Failed to get network interfaces: ${err.message}`); + throw new AppError(`Failed to get network interfaces: ${err.message}`); } } @@ -110,11 +114,14 @@ class EmergencyLockdown { * Disable a network interface */ async disableInterface(interfaceName) { + if (!interfaceName || !SAFE_INTERFACE_NAME.test(interfaceName)) { + throw new InvalidInputError('Invalid interface name.'); + } try { execFileSync('netsh', ['interface', 'set', 'interface', interfaceName, 'admin=disable'], { timeout: 10000 }); return { success: true, interface: interfaceName }; } catch (err) { - throw new Error(`Failed to disable ${interfaceName}: ${err.message}`); + throw new AppError(`Failed to disable ${interfaceName}: ${err.message}`); } } @@ -122,11 +129,14 @@ class EmergencyLockdown { * Enable a network interface */ async enableInterface(interfaceName) { + if (!interfaceName || !SAFE_INTERFACE_NAME.test(interfaceName)) { + throw new InvalidInputError('Invalid interface name.'); + } try { execFileSync('netsh', ['interface', 'set', 'interface', interfaceName, 'admin=enable'], { timeout: 10000 }); return { success: true, interface: interfaceName }; } catch (err) { - throw new Error(`Failed to enable ${interfaceName}: ${err.message}`); + throw new AppError(`Failed to enable ${interfaceName}: ${err.message}`); } } @@ -178,7 +188,7 @@ class EmergencyLockdown { return isNonEssential && isRunning; }); } catch (err) { - throw new Error(`Failed to get services: ${err.message}`); + throw new AppError(`Failed to get services: ${err.message}`); } } @@ -190,7 +200,7 @@ class EmergencyLockdown { execFileSync('sc', ['stop', serviceName], { timeout: 15000 }); return { success: true, service: serviceName }; } catch (err) { - throw new Error(`Failed to stop ${serviceName}: ${err.message}`); + throw new AppError(`Failed to stop ${serviceName}: ${err.message}`); } } @@ -202,7 +212,7 @@ class EmergencyLockdown { execFileSync('sc', ['start', serviceName], { timeout: 15000 }); return { success: true, service: serviceName }; } catch (err) { - throw new Error(`Failed to start ${serviceName}: ${err.message}`); + throw new AppError(`Failed to start ${serviceName}: ${err.message}`); } } @@ -222,7 +232,7 @@ class EmergencyLockdown { const interfaces = await this.getNetworkInterfaces(); const services = await this.getNonEssentialServices(); - this.savedNetworkState = interfaces.map(i => ({ name: i.name, state: i.state })); + this.savedNetworkState = interfaces.map(i => ({ name: i.name.trim(), state: i.state })); this.savedServicesState = services.map(s => ({ name: s.name, state: s.state })); const results = { @@ -282,13 +292,15 @@ class EmergencyLockdown { 'warn' ); + log(this.db, ACTIONS.LOCKDOWN_ACTIVATE, results, { success: true }, true); return { success: true, results }; } catch (err) { // Reset guard on failure so restore() doesn't receive corrupted state this.isLockedDown = false; this.savedNetworkState = null; this.savedServicesState = null; - throw new Error(`Lockdown failed: ${err.message}`); + log(this.db, ACTIONS.LOCKDOWN_ACTIVATE, null, { success: false, error: err.message }, true); + throw new AppError(`Lockdown failed: ${err.message}`); } } @@ -374,9 +386,11 @@ class EmergencyLockdown { ); } + log(this.db, ACTIONS.LOCKDOWN_RESTORE, results, { success: status === 'success', status }, true); return { success: status === 'success', results, status }; } catch (err) { - throw new Error(`Restore failed: ${err.message}`); + log(this.db, ACTIONS.LOCKDOWN_RESTORE, null, { success: false, error: err.message }, true); + throw new AppError(`Restore failed: ${err.message}`); } } diff --git a/src/security/FirewallManager.js b/src/security/FirewallManager.js index 99ed0d3..d290066 100644 --- a/src/security/FirewallManager.js +++ b/src/security/FirewallManager.js @@ -1,7 +1,9 @@ const logger = require('../utils/logger'); +const { InvalidInputError } = require('../utils/errors'); const { execFile } = require('child_process'); const util = require('util'); const execFilePromise = util.promisify(execFile); +const { log, ACTIONS } = require('../core/auditLog'); // Prefix used for every rule this app creates. Destructive/mutating actions // (delete, enable/disable) are restricted to rules carrying this prefix so a @@ -37,6 +39,9 @@ function friendlyFirewallError(e, fallback) { } class FirewallManager { + constructor(db) { + this._db = db; + } async runPowerShell(command) { const { stdout } = await execFilePromise('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000, @@ -153,10 +158,13 @@ class FirewallManager { // omitted is left unrestricted by Windows Firewall's defaults. async createRule(spec) { const { name, direction, action, protocol, remoteAddress, remotePort, localPort, program } = spec || {}; - if (!name || !direction || !action) throw new Error('name, direction, and action are required.'); - if (remoteAddress && !isValidIp(remoteAddress)) throw new Error('Invalid remote address.'); + if (!name || !direction || !action) throw new InvalidInputError('name, direction, and action are required.'); + if (remoteAddress && !isValidIp(remoteAddress)) throw new InvalidInputError('Invalid remote address.'); const fullName = name.startsWith(APP_RULE_PREFIX) ? name : `${APP_RULE_PREFIX}${name}`; + if (fullName.includes("'")) { + throw new InvalidInputError('Rule name contains an invalid character.'); + } const parts = [ `-DisplayName '${psEscape(fullName)}'`, `-Direction ${direction === 'Inbound' ? 'Inbound' : 'Outbound'}`, @@ -167,14 +175,14 @@ class FirewallManager { if (remotePort != null && remotePort !== '') { const port = Number(remotePort); if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid remotePort: ${remotePort}`); + throw new InvalidInputError(`Invalid remotePort: ${remotePort}`); } parts.push(`-RemotePort ${port}`); } if (localPort != null && localPort !== '') { const port = Number(localPort); if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid localPort: ${localPort}`); + throw new InvalidInputError(`Invalid localPort: ${localPort}`); } parts.push(`-LocalPort ${port}`); } @@ -185,30 +193,33 @@ class FirewallManager { } catch (e) { throw friendlyFirewallError(e, 'Could not create the firewall rule.'); } + log(this._db, ACTIONS.FIREWALL_RULE_CREATE, { name: fullName, spec }, { success: true }); return { success: true, name: fullName }; } async deleteRule(name) { if (!name || !name.startsWith(APP_RULE_PREFIX)) { - throw new Error('Only rules created in this app can be deleted here.'); + throw new InvalidInputError('Only rules created in this app can be deleted here.'); } try { await this.runPowerShell(`Remove-NetFirewallRule -DisplayName '${psEscape(name)}'`); } catch (e) { throw friendlyFirewallError(e, 'Could not delete that rule.'); } + log(this._db, ACTIONS.FIREWALL_RULE_DELETE, { name }, { success: true }); return { success: true }; } async setRuleEnabled(name, enabled) { if (!name || !name.startsWith(APP_RULE_PREFIX)) { - throw new Error('Only rules created in this app can be toggled here.'); + throw new InvalidInputError('Only rules created in this app can be toggled here.'); } try { await this.runPowerShell(`Set-NetFirewallRule -DisplayName '${psEscape(name)}' -Enabled ${enabled ? 'True' : 'False'}`); } catch (e) { throw friendlyFirewallError(e, 'Could not update that rule.'); } + log(this._db, ACTIONS.FIREWALL_RULE_TOGGLE, { name, enabled }, { success: true }); return { success: true }; } @@ -219,7 +230,7 @@ class FirewallManager { async setProfileEnabled(profile, enabled) { const VALID_PROFILES = ['Domain', 'Private', 'Public']; if (!VALID_PROFILES.includes(profile)) { - throw new Error('Invalid firewall profile.'); + throw new InvalidInputError('Invalid firewall profile.'); } try { await this.runPowerShell(`Set-NetFirewallProfile -Name ${profile} -Enabled ${enabled ? 'True' : 'False'}`); @@ -258,11 +269,11 @@ class FirewallManager { if (/^any$/i.test(raw)) return undefined; // Fail closed: do not silently drop ranges/lists/keywords (e.g. "80,443", "1-65535", "RPC"). if (!/^\d{1,5}$/.test(raw)) { - throw new Error(`Unsupported port expression (import supports a single numeric port only): ${value}`); + throw new InvalidInputError(`Unsupported port expression (import supports a single numeric port only): ${value}`); } const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 65535) { - throw new Error(`Invalid port value: ${value}`); + throw new InvalidInputError(`Invalid port value: ${value}`); } return n; } @@ -272,7 +283,7 @@ class FirewallManager { const protocol = String(value).trim(); const allowed = new Set(['TCP', 'UDP', 'ICMPv4', 'ICMPv6', 'Any']); if (!allowed.has(protocol)) { - throw new Error(`Unsupported protocol: ${protocol}`); + throw new InvalidInputError(`Unsupported protocol: ${protocol}`); } return protocol === 'Any' ? undefined : protocol; } @@ -284,34 +295,38 @@ class FirewallManager { // Multi-value / range exports are not re-imported as address filters. if (raw.includes(',')) return undefined; if (!isValidIp(raw)) { - throw new Error(`Invalid remote address: ${raw}`); + throw new InvalidInputError(`Invalid remote address: ${raw}`); } return raw; } _validateImportRule(rule, index) { if (!rule || typeof rule !== 'object' || Array.isArray(rule)) { - throw new Error(`Rule at index ${index} is invalid.`); + throw new InvalidInputError(`Rule at index ${index} is invalid.`); } if (!rule.name || !rule.direction || !rule.action) { - throw new Error(`Rule at index ${index} is missing name, direction, or action.`); + throw new InvalidInputError(`Rule at index ${index} is missing name, direction, or action.`); } if (String(rule.name).length > 256) { - throw new Error(`Rule at index ${index} has a name that is too long.`); + throw new InvalidInputError(`Rule at index ${index} has a name that is too long.`); } const dir = String(rule.direction); const action = String(rule.action); if (dir !== 'Inbound' && dir !== 'Outbound') { - throw new Error(`Rule "${rule.name}" has an invalid direction.`); + throw new InvalidInputError(`Rule "${rule.name}" has an invalid direction.`); } if (action !== 'Allow' && action !== 'Block') { - throw new Error(`Rule "${rule.name}" has an invalid action.`); + throw new InvalidInputError(`Rule "${rule.name}" has an invalid action.`); } // Throw early for bad address/port/protocol shapes before shelling out. - this._normalizeRemoteAddress(rule.remoteAddress); - this._normalizePort(rule.remotePort); - this._normalizePort(rule.localPort); - this._normalizeProtocol(rule.protocol); + try { + this._normalizeRemoteAddress(rule.remoteAddress); + this._normalizePort(rule.remotePort); + this._normalizePort(rule.localPort); + this._normalizeProtocol(rule.protocol); + } catch (normErr) { + throw new InvalidInputError(`Rule at index ${index} has invalid fields: ${normErr.message}`); + } } async importRules(payload, options = {}) { @@ -320,17 +335,17 @@ class FirewallManager { : 'skip'; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('Import payload must be a JSON object.'); + throw new InvalidInputError('Import payload must be a JSON object.'); } if (payload.version != null && Number(payload.version) !== 1) { - throw new Error(`Unsupported firewall export version: ${payload.version}`); + throw new InvalidInputError(`Unsupported firewall export version: ${payload.version}`); } const rules = Array.isArray(payload.rules) ? payload.rules : null; if (!rules) { - throw new Error('Import file must include a "rules" array.'); + throw new InvalidInputError('Import file must include a "rules" array.'); } if (rules.length > 500) { - throw new Error('Import file contains too many rules (limit 500).'); + throw new InvalidInputError('Import file contains too many rules (limit 500).'); } const existing = await this.listRules(); @@ -383,7 +398,7 @@ class FirewallManager { }); } catch (createErr) { if (mode === 'overwrite') { - throw new Error( + throw new InvalidInputError( `Rule "${name}" was removed during overwrite but could not be recreated: ${createErr.message || createErr}` ); } diff --git a/src/security/FolderWatcher.js b/src/security/FolderWatcher.js index cd0e1d9..9ace2c0 100644 --- a/src/security/FolderWatcher.js +++ b/src/security/FolderWatcher.js @@ -3,6 +3,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const logger = require('../utils/logger'); /** * Watches high-risk directories and queues custom scans when files appear @@ -67,7 +68,9 @@ class FolderWatcher { stop() { this._running = false; for (const [, watcher] of this._watchers) { - try { watcher.close(); } catch (_) {} + try { watcher.close(); } catch (err) { + logger.debug('FolderWatcher close failed', { error: err.message }); + } } this._watchers.clear(); for (const timer of this._pending.values()) clearTimeout(timer); @@ -86,7 +89,9 @@ class FolderWatcher { this._schedule(fullPath); }); watcher.on('error', () => { - try { watcher.close(); } catch (_) {} + try { watcher.close(); } catch (err) { + logger.debug('FolderWatcher error-close failed', { error: err.message }); + } this._watchers.delete(dir); }); this._watchers.set(dir, watcher); diff --git a/src/security/GeoLocationService.js b/src/security/GeoLocationService.js index 2d942a6..50844b3 100644 --- a/src/security/GeoLocationService.js +++ b/src/security/GeoLocationService.js @@ -1,24 +1,11 @@ -const https = require('https'); +const { requestText } = require('../main/ipc/_shared'); -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(); - }); -} +/** + * GeoLocationService — looks up geographic data for an IP address. + * Results are cached in the database to avoid redundant API calls. + * In-flight requests are deduplicated so concurrent lookups for the + * same IP only make one network request. + */ /** * GeoLocationService — looks up geographic data for an IP address. diff --git a/src/security/NetworkMonitor.js b/src/security/NetworkMonitor.js index 999e90e..7297b03 100644 --- a/src/security/NetworkMonitor.js +++ b/src/security/NetworkMonitor.js @@ -1,18 +1,34 @@ const logger = require('../utils/logger'); -const { exec } = require('child_process'); +const { execFile } = require('child_process'); const util = require('util'); const si = require('systeminformation'); -const execPromise = util.promisify(exec); +const path = require('path'); +const fs = require('fs'); +const execFilePromise = util.promisify(execFile); +const { NotFoundError } = require('../utils/errors'); + +const PS_SCRIPTS_DIR = path.join(__dirname, 'scripts'); + +async function runPs1(scriptName) { + const scriptPath = path.join(PS_SCRIPTS_DIR, scriptName); + if (!fs.existsSync(scriptPath)) { + throw new NotFoundError(`PowerShell script not found: ${scriptPath}`); + } + const { stdout } = await execFilePromise('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath + ], { timeout: 15000, windowsHide: true }); + return stdout; +} class NetworkMonitor { async getConnections() { try { - const { stdout } = await execPromise(`powershell.exe -NoProfile -NonInteractive -Command "Get-NetTCPConnection | Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess | ConvertTo-Json -Compress"`); + const stdout = await runPs1('network-connections.ps1'); let connections = JSON.parse(stdout || '[]'); if (!Array.isArray(connections)) connections = [connections]; return connections; } catch (e) { - logger.error('Failed to get network connections', e); + logger.error('Failed to get network connections', { error: e.message || String(e) }); return []; } } @@ -28,26 +44,25 @@ class NetworkMonitor { txTotal: Math.round((s.tx_bytes || 0) / (1024 * 1024) * 10) / 10 })); - // Use a script file to avoid PowerShell quoting issues - const psScript = `$conns = Get-NetTCPConnection; $total = $conns.Count; $established = ($conns | Where-Object { $_.State -eq 'Established' }).Count; $listen = ($conns | Where-Object { $_.State -eq 'Listen' }).Count; $timeWait = ($conns | Where-Object { $_.State -eq 'TimeWait' }).Count; $closeWait = ($conns | Where-Object { $_.State -eq 'CloseWait' }).Count; Write-Output ($total, $established, $listen, $timeWait, $closeWait -join '|')`; - const { stdout } = await execPromise(`powershell.exe -NoProfile -NonInteractive -Command "${psScript}"`, { timeout: 10000 }); - const parts = stdout.trim().split('|'); + const stdout = await runPs1('network-stats.ps1'); + const data = JSON.parse(stdout || '{}'); + const conn = data.connections || {}; return { interfaces: interfaceStats, connections: { - total: parseInt(parts[0]) || 0, - established: parseInt(parts[1]) || 0, - listen: parseInt(parts[2]) || 0, - timeWait: parseInt(parts[3]) || 0, - closeWait: parseInt(parts[4]) || 0 + total: parseInt(conn.total, 10) || 0, + established: parseInt(conn.established, 10) || 0, + listen: parseInt(conn.listen, 10) || 0, + timeWait: parseInt(conn.timeWait, 10) || 0, + closeWait: parseInt(conn.closeWait, 10) || 0 } }; } catch (e) { - logger.error('Failed to get network stats', e); + logger.error('Failed to get network stats', { error: e.message || String(e) }); return { interfaces: [], connections: { total: 0, established: 0, listen: 0, timeWait: 0, closeWait: 0 } }; } } } -module.exports = NetworkMonitor; \ No newline at end of file +module.exports = NetworkMonitor; diff --git a/src/security/ProcessInspector.js b/src/security/ProcessInspector.js index e0e0731..324fe98 100644 --- a/src/security/ProcessInspector.js +++ b/src/security/ProcessInspector.js @@ -5,6 +5,7 @@ const util = require('util'); const execPromise = util.promisify(exec); const { suspiciousPathSignals, getSignatureInfo } = require('./windowsChecks'); const logger = require('../utils/logger'); +const { log, ACTIONS } = require('../core/auditLog'); // PIDs that should never be terminated regardless of what they resolve to. const PROTECTED_PIDS = new Set([0, 4]); @@ -50,6 +51,7 @@ function isSystemDirectoryPath(filePath) { // ps-list is ESM-only so we must use dynamic import() class ProcessInspector { constructor(options = {}) { + this._db = options.db || null; this._getSignatureInfo = options.getSignatureInfo || getSignatureInfo; } @@ -169,9 +171,11 @@ class ProcessInspector { // terminating arbitrary third-party processes, including ones that // don't respond to a plain terminate signal. await execPromise(`taskkill /PID ${numericPid} /F`, { timeout: 10000 }); + log(this._db, ACTIONS.PROCESS_KILL, { pid: numericPid, name: target.name }, { success: true }); return { success: true }; } catch (err) { const message = (err.stderr && err.stderr.trim()) || err.message || 'Unknown error ending process.'; + log(this._db, ACTIONS.PROCESS_KILL, { pid: numericPid, name: target.name }, { success: false, error: message }); return { success: false, error: message }; } } diff --git a/src/security/QuarantineManager.js b/src/security/QuarantineManager.js index 8ace7a0..a97ab39 100644 --- a/src/security/QuarantineManager.js +++ b/src/security/QuarantineManager.js @@ -1,18 +1,16 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); +const crypto = require('crypto'); const logger = require('../utils/logger'); +const { InvalidInputError } = require('../utils/errors'); +const { log, ACTIONS } = require('../core/auditLog'); -// XOR key used to obfuscate quarantined files. This is not cryptographic -// security — it's just enough to prevent accidental double-click execution. -// Both quarantine() and restore() must use the same value. -const QUARANTINE_XOR_KEY = 0x55; +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_HASH = 'sha256'; +const PBKDF2_KEY_LENGTH = 32; // 256-bit AES key +const PBKDF2_SALT = Buffer.from('Soterios-Quarantine-KDF-v1', 'utf8'); -/** - * QuarantineManager — isolates detected threat files by XOR-encrypting - * them (key `0x55`) and moving them to a dedicated quarantine directory. - * Files can later be restored to their original path or permanently deleted. - */ class QuarantineManager { /** * @param {object} db - DatabaseService with quarantine record helpers. @@ -25,10 +23,38 @@ class QuarantineManager { if (!fs.existsSync(this.quarantineDir)) { fs.mkdirSync(this.quarantineDir, { recursive: true }); } + + // Derive a machine-specific encryption key. This is not a password — it's + // a convenience secret so quarantined files from one machine cannot be + // trivially decrypted on another. A determined local attacker can still + // recover the key from memory, but casual inspection of the quarantined + // file is no longer sufficient. + const machineSecret = `${os.hostname()}\x00${os.userInfo().username}`; + this._key = crypto.pbkdf2Sync(machineSecret, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, PBKDF2_HASH); + } + + _encrypt(data) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this._key, iv); + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]); + } + + _decrypt(buffer) { + if (buffer.length < 28) { + throw new InvalidInputError('Quarantined file is too short to be valid.'); + } + const iv = buffer.slice(0, 12); + const tag = buffer.slice(12, 28); + const encrypted = buffer.slice(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', this._key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); } /** - * XOR-encrypt a threat file into quarantine, record it, then remove the original. + * AES-256-GCM-encrypt a threat file into quarantine, record it, then remove the original. * @param {string} originalPath * @param {string} hash * @param {string} engine @@ -43,12 +69,9 @@ class QuarantineManager { const safeName = `${Date.now()}_${fileName}.encrypted`; quarantinePath = path.join(this.quarantineDir, safeName); - // Basic XOR encryption to prevent accidental execution const data = fs.readFileSync(originalPath); - for (let i = 0; i < data.length; i++) { - data[i] ^= QUARANTINE_XOR_KEY; - } - fs.writeFileSync(quarantinePath, data); + const encrypted = this._encrypt(data); + fs.writeFileSync(quarantinePath, encrypted); const res = this.db.addQuarantineRecord({ originalPath, @@ -62,10 +85,10 @@ class QuarantineManager { // Only delete original file after DB record is successfully created fs.unlinkSync(originalPath); + log(this.db, ACTIONS.QUARANTINE_ADD, { originalPath, hash, engine, threatName, reason }, { success: true, id: res.lastInsertRowid }); return { success: true, id: res.lastInsertRowid }; } catch (err) { logger.error('Failed to quarantine', { error: err.message || String(err) }); - // If DB failed but we already encrypted the file, clean it up try { if (quarantinePath && fs.existsSync(quarantinePath)) { fs.unlinkSync(quarantinePath); @@ -75,6 +98,7 @@ class QuarantineManager { error: cleanupErr.message || String(cleanupErr) }); } + log(this.db, ACTIONS.QUARANTINE_ADD, { originalPath, hash, engine, threatName, reason }, { success: false, error: err.message }); return { success: false, error: err.message }; } } @@ -95,9 +119,12 @@ class QuarantineManager { return { success: false, error: 'Quarantined file is missing from disk.' }; } - const data = fs.readFileSync(record.quarantine_path); - for (let i = 0; i < data.length; i++) { - data[i] ^= QUARANTINE_XOR_KEY; + const encrypted = fs.readFileSync(record.quarantine_path); + let data; + try { + data = this._decrypt(encrypted); + } catch (decErr) { + return { success: false, error: 'Quarantined file integrity check failed — file may have been tampered with.' }; } const destDir = path.dirname(record.original_path); @@ -109,8 +136,10 @@ class QuarantineManager { fs.unlinkSync(record.quarantine_path); this.db.updateQuarantineStatus(id, 'restored'); + log(this.db, ACTIONS.QUARANTINE_RESTORE, { id, originalPath: record.original_path }, { success: true }); return { success: true }; } catch (err) { + log(this.db, ACTIONS.QUARANTINE_RESTORE, { id }, { success: false, error: err.message }); return { success: false, error: err.message }; } } @@ -131,8 +160,10 @@ class QuarantineManager { fs.unlinkSync(record.quarantine_path); } this.db.updateQuarantineStatus(id, 'deleted'); + log(this.db, ACTIONS.QUARANTINE_DELETE, { id, originalPath: record.original_path }, { success: true }); return { success: true }; } catch (err) { + log(this.db, ACTIONS.QUARANTINE_DELETE, { id }, { success: false, error: err.message }); return { success: false, error: err.message }; } } diff --git a/src/security/ScanEngine.js b/src/security/ScanEngine.js index 371e38e..c25357b 100644 --- a/src/security/ScanEngine.js +++ b/src/security/ScanEngine.js @@ -1,18 +1,13 @@ const logger = require('../utils/logger'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const crypto = require('crypto'); const { clampProgress } = require('../core/scanProgress'); +const { scanReportsDir } = require('./reportExport'); +const { renderTemplate } = require('../utils/templates'); function esc(v) { - return String(v ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); -} - -function scanReportsDir() { - const dir = path.join(os.homedir(), '.soterios', 'scan-reports'); - fs.mkdirSync(dir, { recursive: true }); - return dir; + return String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } function renderScanReportHtml(report) { @@ -22,23 +17,20 @@ function renderScanReportHtml(report) { const errors = report.errors.length ? report.errors.map((e) => `
  • ${esc(e)}
  • `).join('') : '
  • No scan errors recorded.
  • '; - return ` -Soterios Scan Report - - -

    Soterios Scan Report

    -
    Generated ${esc(new Date(report.completedAt).toLocaleString())}
    -
    -
    Type

    ${esc(report.scanType)}

    -
    Status

    ${esc(report.status)}

    -
    Files Scanned

    ${esc(report.filesScanned)}

    -
    Threats

    ${esc(report.threatsFound)}

    -
    -

    Targets

    ${esc(report.targetPaths.join('\n'))}
    -

    Threat Details

    -${threatRows}
    NamePath
    -

    Errors and Notes

      ${errors}
    -`; + const statusClass = report.status === 'completed' ? 'ok' : 'warn'; + const threatsClass = report.threatsFound ? 'danger' : 'ok'; + return renderTemplate(path.join(__dirname, '..', 'ui', 'templates', 'scan-report.html'), { + GENERATED_AT: new Date(report.completedAt).toLocaleString(), + SCAN_TYPE: esc(report.scanType), + STATUS_CLASS: statusClass, + STATUS: esc(report.status), + FILES_SCANNED: esc(report.filesScanned), + THREATS_CLASS: threatsClass, + THREATS_FOUND: esc(report.threatsFound), + TARGETS: esc(report.targetPaths.join('\n')), + THREAT_ROWS: threatRows, + ERRORS: errors, + }); } class ScanEngine { @@ -140,7 +132,10 @@ class ScanEngine { let totalThreatsFound = 0; const threats = []; const errors = []; - let wasCanceled = false; + + // Build skip set for incremental scans (full scans only). + const isFullScan = scanType === 'full'; + const skipPaths = isFullScan ? this.db.getFilesToSkip(paths) : new Set(); // Progress must never move backward within a single scan. Previously, // each target path computed its own fresh, lower "basePct" and emitted @@ -160,6 +155,7 @@ class ScanEngine { this.eventBus.emit('scan:progress', { scanType, pct, message, ...extra }); }; + let wasCanceled = false; try { emitProgress(5, startMessage); @@ -170,6 +166,13 @@ class ScanEngine { } const targetPath = paths[i]; + + // Incremental scan: skip paths that haven't changed since last scan. + if (skipPaths.has(targetPath)) { + emitProgress(basePct, 'Skipping unchanged: ' + targetPath + '...', { filesScanned: cumulativeFiles }); + continue; + } + const basePct = Math.round((i / paths.length) * 80 + 10); emitProgress(basePct, 'Scanning ' + targetPath + '...'); @@ -207,6 +210,18 @@ class ScanEngine { scanState.notes.push(result.note); } + // Record scanned path for incremental scans. + try { + const stat = fs.statSync(targetPath); + this.db.recordScannedFile({ + path: targetPath, + size: stat.size, + modifiedAt: stat.mtime.toISOString() + }); + } catch (_) { + // Non-fatal: record best-effort for incremental cache. + } + // Quarantine each newly-found threat from this iteration if (Array.isArray(result.threats)) { for (const threat of result.threats) { @@ -272,7 +287,9 @@ class ScanEngine { if (shouldPersistReport && this.db.getSetting('feature.scanHistory', true)) { this.db.logScan(scanType, totalFilesScanned, totalThreatsFound, durationMs); } - } catch (_) {} + } catch (err) { + logger.debug('Scan history log failed', { error: err.message }); + } scanState.currentScan = null; scanState.abortController = null; if (wasCanceled) { diff --git a/src/security/reportExport.js b/src/security/reportExport.js index 0a45a08..3098033 100644 --- a/src/security/reportExport.js +++ b/src/security/reportExport.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const { NotFoundError } = require('../utils/errors'); function scanReportsDir() { const dir = path.join(os.homedir(), '.soterios', 'scan-reports'); @@ -136,7 +137,7 @@ function safeWriteFileSync(destPath, data, encoding) { async function generatePdfFromHtml(htmlPath) { if (!htmlPath || !fs.existsSync(htmlPath)) { - throw new Error('Report HTML file not found.'); + throw new NotFoundError('Report HTML file not found.'); } const { BrowserWindow } = require('electron'); diff --git a/src/security/scripts/network-connections.ps1 b/src/security/scripts/network-connections.ps1 new file mode 100644 index 0000000..00a7c51 --- /dev/null +++ b/src/security/scripts/network-connections.ps1 @@ -0,0 +1,8 @@ +# network-connections.ps1 +# Returns all TCP connections as JSON. +# Output: array of { LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess } + +$ErrorActionPreference = 'Stop' +Get-NetTCPConnection | + Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess | + ConvertTo-Json -Compress diff --git a/src/security/scripts/network-stats.ps1 b/src/security/scripts/network-stats.ps1 new file mode 100644 index 0000000..df0e60b --- /dev/null +++ b/src/security/scripts/network-stats.ps1 @@ -0,0 +1,34 @@ +# network-stats.ps1 +# Returns interface stats and TCP connection state counts as JSON. +# Output: { interfaces: [...], connections: { total, established, listen, timeWait, closeWait } } + +$ErrorActionPreference = 'Stop' + +$netStats = Get-NetTCPConnection +$total = $netStats.Count +$established = ($netStats | Where-Object { $_.State -eq 'Established' }).Count +$listen = ($netStats | Where-Object { $_.State -eq 'Listen' }).Count +$timeWait = ($netStats | Where-Object { $_.State -eq 'TimeWait' }).Count +$closeWait = ($netStats | Where-Object { $_.State -eq 'CloseWait' }).Count + +$interfaces = Get-NetAdapter -ErrorAction SilentlyContinue | ForEach-Object { + $stats = Get-NetAdapterStatistics -Name $_.Name -ErrorAction SilentlyContinue + [PSCustomObject]@{ + iface = $_.Name + rxSec = 0 + txSec = 0 + rxTotal = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB * 10) / 10 } else { 0 } + txTotal = if ($stats) { [math]::Round($stats.SentBytes / 1MB * 10) / 10 } else { 0 } + } +} + +[PSCustomObject]@{ + interfaces = $interfaces + connections = [PSCustomObject]@{ + total = $total + established = $established + listen = $listen + timeWait = $timeWait + closeWait = $closeWait + } +} | ConvertTo-Json -Compress diff --git a/src/ui/js/api.js b/src/ui/js/api.js index b7d048b..43617b8 100644 --- a/src/ui/js/api.js +++ b/src/ui/js/api.js @@ -12,7 +12,7 @@ const Api = { if (window.AppState) window.AppState.currentTheme = finalTheme; try { if (window.localStorage) window.localStorage.setItem('soterios.theme', finalTheme); - } catch (_) {} + } catch (e) { console.debug?.('Theme localStorage write failed', { error: e?.message || String(e) }); } }, async initializeTheme() { if (window.AppState && window.AppState.currentTheme) { @@ -25,14 +25,14 @@ const Api = { this.applyTheme(theme); return; } - } catch (_) {} + } catch (e) { console.debug?.('Theme db read failed', { error: e?.message || String(e) }); } try { const storedTheme = window.localStorage && window.localStorage.getItem('soterios.theme'); if (storedTheme) { this.applyTheme(storedTheme); return; } - } catch (_) {} + } catch (e) { console.debug?.('Theme localStorage read failed', { error: e?.message || String(e) }); } this.applyTheme('dark'); }, async initializeLanguage() { @@ -49,10 +49,10 @@ const Api = { // re-write it, and the transient failure fallback below must never // clobber it. await window.I18n.setLocale(locale || 'en', { persist: !saved }); - } catch (_) { + } catch (e) { try { await window.I18n.setLocale('en', { persist: false }); - } catch (_) {} + } catch (e2) { console.debug?.('I18n fallback locale failed', { error: e2?.message || String(e2) }); } } }, async listTools() { return window.soterios.tools.list(); }, @@ -181,7 +181,7 @@ const Api = { await window.api.invoke('db:setSetting', 'ui.theme', u.theme || 'dark'); try { if (window.localStorage) window.localStorage.setItem('soterios.theme', u.theme || 'dark'); - } catch (_) {} + } catch (e) { console.debug?.('Settings theme localStorage write failed', { error: e?.message || String(e) }); } } if (Object.prototype.hasOwnProperty.call(u, 'language') && window.I18n) { await window.I18n.setLocale(u.language || 'en'); diff --git a/src/ui/js/pages/network.js b/src/ui/js/pages/network.js index 6f1c1bc..02abc93 100644 --- a/src/ui/js/pages/network.js +++ b/src/ui/js/pages/network.js @@ -707,7 +707,7 @@ window.Pages['network'] = { let status = { recentHits: [] }; try { status = await window.api.invoke('network-alerts:status') || status; - } catch (_) {} + } catch (e) { console.debug?.('Network alerts status fetch failed', { error: e?.message || String(e) }); } const hits = status.recentHits || []; const hitsKey = hits.map(h => h.key).join('|'); diff --git a/src/ui/js/pages/settings.js b/src/ui/js/pages/settings.js index 0e526ee..ebcb9ec 100644 --- a/src/ui/js/pages/settings.js +++ b/src/ui/js/pages/settings.js @@ -47,7 +47,7 @@ window.Pages.settings = { if (catalog && catalog['settings.languageInDevelopment']) { languageInDevMap[code] = catalog['settings.languageInDevelopment']; } - } catch (_) {} + } catch (e) { console.debug?.('Settings i18n catalog fetch failed', { code, error: e?.message || String(e) }); } } })); localeOptions = locales.map(({ code, label }) => { diff --git a/src/ui/js/router.js b/src/ui/js/router.js index 71e620e..cb0181d 100644 --- a/src/ui/js/router.js +++ b/src/ui/js/router.js @@ -21,7 +21,7 @@ if (currentPage && currentPage !== pageId) { const prev = window.Pages[currentPage]; if (prev && typeof prev.destroy === 'function') { - try { prev.destroy(); } catch (_) {} + try { prev.destroy(); } catch (e) { console.debug?.('Router page destroy failed', { page: currentPage, error: e?.message || String(e) }); } } } navItems.forEach((item) => { item.classList.toggle('active', item.dataset.page === pageId); }); diff --git a/src/ui/templates/scan-report.html b/src/ui/templates/scan-report.html new file mode 100644 index 0000000..cdfcda8 --- /dev/null +++ b/src/ui/templates/scan-report.html @@ -0,0 +1,28 @@ + +Soterios Scan Report + + +

    Soterios Scan Report

    +
    Generated {{GENERATED_AT}}
    +
    +
    Type

    {{SCAN_TYPE}}

    +
    Status

    {{STATUS}}

    +
    Files Scanned

    {{FILES_SCANNED}}

    +
    Threats

    {{THREATS_FOUND}}

    +
    +

    Targets

    {{TARGETS}}
    +

    Threat Details

    +{{THREAT_ROWS}}
    NamePath
    +

    Errors and Notes

      {{ERRORS}}
    + diff --git a/src/ui/templates/toast.html b/src/ui/templates/toast.html new file mode 100644 index 0000000..3fd622b --- /dev/null +++ b/src/ui/templates/toast.html @@ -0,0 +1,75 @@ + + + +
    +
    + {{MARK_DATA_URI}} + {{WORDMARK_DATA_URI}} +
    +
    ×
    +
    +
    +
    + {{ICON_PATHS}} +
    +
    +
    {{TITLE}}
    +
    {{BODY}}
    +
    +
    +
    + + diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..fc511f5 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Structured error types for Soterios. + * + * Each class carries a machine-readable `code` and an optional `cause` + * so callers can branch on error.code instead of regex-matching messages. + */ + +class AppError extends Error { + /** + * @param {string} message + * @param {{ code: string, cause?: Error }} [options] + */ + constructor(message, options = {}) { + super(message); + this.name = this.constructor.name; + this.code = options.code || 'app_error'; + this.cause = options.cause || null; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + cause: this.cause ? { name: this.cause.name, message: this.cause.message } : null + }; + } +} + +class NotFoundError extends AppError { + /** + * @param {string} [message] + * @param {{ cause?: Error }} [options] + */ + constructor(message = 'Resource not found.', options = {}) { + super(message, { ...options, code: 'not_found' }); + } +} + +class PermissionError extends AppError { + /** + * @param {string} [message] + * @param {{ cause?: Error }} [options] + */ + constructor(message = 'Permission denied.', options = {}) { + super(message, { ...options, code: 'permission_denied' }); + } +} + +class TimeoutError extends AppError { + /** + * @param {string} [message] + * @param {{ cause?: Error }} [options] + */ + constructor(message = 'Operation timed out.', options = {}) { + super(message, { ...options, code: 'timeout' }); + } +} + +class InvalidInputError extends AppError { + /** + * @param {string} [message] + * @param {{ cause?: Error }} [options] + */ + constructor(message = 'Invalid input.', options = {}) { + super(message, { ...options, code: 'invalid_input' }); + } +} + +module.exports = { + AppError, + NotFoundError, + PermissionError, + TimeoutError, + InvalidInputError +}; diff --git a/src/utils/templates.js b/src/utils/templates.js new file mode 100644 index 0000000..98f3af6 --- /dev/null +++ b/src/utils/templates.js @@ -0,0 +1,18 @@ +const fs = require('fs'); + +/** + * Render a template file by replacing {{KEY}} placeholders with values. + * + * @param {string} filePath - Absolute path to the template file. + * @param {Record} data - Key/value pairs to substitute. + * @returns {string} Rendered HTML. + */ +function renderTemplate(filePath, data = {}) { + let html = fs.readFileSync(filePath, 'utf8'); + for (const [key, value] of Object.entries(data)) { + html = html.split(`{{${key}}}`).join(String(value)); + } + return html; +} + +module.exports = { renderTemplate }; diff --git a/tests/baseline-coverage.txt b/tests/baseline-coverage.txt new file mode 100644 index 0000000..a137521 --- /dev/null +++ b/tests/baseline-coverage.txt @@ -0,0 +1,46 @@ +Soterios Test Baseline — recorded 2026-08-01 +============================================= +Command: node tests/node-test-runner.js +Result: 282 passed, 0 failed, 0 cancelled, 0 skipped +Duration: ~2696ms +Suites: 53 + +Pre-existing failure modes: +- systeminformation module not installed before npm install + (fixed by running npm install; no test code changes needed) + +Notes for future comparison: +- All 282 tests pass cleanly before Phase 1 changes. +- Watch for regressions in: + - tests/windowsChecks.test.js + - tests/blocklistService.test.js + - tests/networkAlertMonitor.test.js + - tests/processInspector.test.js + - tests/scanEngine.test.js + - tests/quarantineManager.test.js + - tests/reportExport.test.js + - tests/firewallManager.test.js + - tests/emergencyLockdown.test.js + - tests/realTimeWatcher.test.js + - tests/clamavEngine.test.js + - tests/heuristicEngine.test.js + - tests/scoringEngine.test.js + - tests/reputationEngine.test.js + - tests/database.test.js + - tests/logger.test.js + - tests/maintenanceScheduler.test.js + - tests/i18n.test.js + - tests/platform.test.js + - tests/passwordTools.test.js + - tests/fileShredder.test.js + - tests/duplicateFileFinder.test.js + - tests/healthScore.test.js + - tests/healthSummary.test.js + - tests/scanCancellation.test.js + - tests/workerManager.test.js + - tests/uninstallUtils.test.js + - tests/uninstallLaunchUtils.test.js + - tests/uninstallerReport.test.js + - tests/systemAudit.test.js + - tests/networkStats.test.js + - tests/folderWatcher.test.js diff --git a/tools/validate-native-host.js b/tools/validate-native-host.js new file mode 100644 index 0000000..9f3f2df --- /dev/null +++ b/tools/validate-native-host.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); + +function validateNativeHostManifest() { + const manifestPath = path.join(__dirname, '..', 'browser-extension', 'native-host-manifest.json'); + if (!fs.existsSync(manifestPath)) { + console.error('[validate-native-host] Manifest not found:', manifestPath); + process.exit(1); + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const origins = Array.isArray(manifest.allowed_origins) ? manifest.allowed_origins : []; + const hasPlaceholder = origins.some((origin) => { + const trimmed = String(origin || '').trim(); + return trimmed === 'chrome-extension:///' + || trimmed === 'chrome-extension://__EXTENSION_ID_PLACEHOLDER__/'; + }); + + if (hasPlaceholder) { + console.error('[validate-native-host] allowed_origins contains a placeholder. Replace __EXTENSION_ID_PLACEHOLDER__ with the actual Chrome extension ID before release.'); + process.exit(1); + } + + if (!origins.length) { + console.error('[validate-native-host] allowed_origins is empty. Add at least one allowed origin.'); + process.exit(1); + } + + // Verify the native-host.bat file referenced in manifest.path exists. + const hostScript = path.join(__dirname, '..', 'browser-extension', manifest.path); + if (!fs.existsSync(hostScript)) { + console.error('[validate-native-host] Native host script not found:', hostScript); + process.exit(1); + } + + console.log('[validate-native-host] OK — allowed_origins:', origins.join(', ')); +} + +validateNativeHostManifest(); From e9afb0d8bf0245d584bde7f5cdaea87b0ffba965 Mon Sep 17 00:00:00 2001 From: schvarts1 Date: Sun, 2 Aug 2026 10:40:28 +0200 Subject: [PATCH 2/2] Docstring + bug correction - unfinished --- src/core/auditLog.js | 21 ++ src/core/database.js | 397 +++++++++++++++++++++++++++- src/core/eventBus.js | 36 +++ src/core/featureFlags.js | 56 ++-- src/core/pluginLoader.js | 4 + src/core/scanProgress.js | 14 +- src/core/toolRegistry.js | 29 ++ src/main/ipc/_shared.js | 11 + src/main/ipc/firewall.js | 17 ++ src/main/ipc/network.js | 40 ++- src/main/ipc/process.js | 6 + src/main/ipc/quarantine.js | 6 + src/main/ipc/scan.js | 10 + src/main/ipc/system.js | 27 +- src/main/ipcHandlers.js | 9 + src/main/lifecycle.js | 141 +++++++++- src/main/main.js | 26 +- src/main/trayDashboard.js | 22 ++ src/main/updater.js | 31 ++- src/main/windowManager.js | 121 ++++++++- src/security/ClamAVEngine.js | 10 + src/security/ClamAVEngineBase.js | 102 ++++++- src/security/EmergencyLockdown.js | 61 ++++- src/security/FirewallManager.js | 96 ++++++- src/security/FolderWatcher.js | 27 ++ src/security/HeuristicEngine.js | 16 ++ src/security/NetworkAlertMonitor.js | 31 +++ src/security/NetworkMonitor.js | 14 + src/security/ProcessInspector.js | 41 ++- src/security/QuarantineManager.js | 90 ++++++- src/security/RealTimeWatcher.js | 30 +++ src/security/ReputationEngine.js | 37 +++ src/security/ScanEngine.js | 138 +++++++++- src/security/SystemAudit.js | 54 ++++ src/tools/actionCenter.js | 16 ++ src/tools/cleanupTool.js | 11 + src/tools/passwordTools.js | 7 + src/utils/quarantineKeyStore.js | 82 ++++++ 38 files changed, 1777 insertions(+), 110 deletions(-) create mode 100644 src/utils/quarantineKeyStore.js diff --git a/src/core/auditLog.js b/src/core/auditLog.js index 4eddfc3..dc424dd 100644 --- a/src/core/auditLog.js +++ b/src/core/auditLog.js @@ -1,5 +1,16 @@ +/** + * Audit logging constants and helper. + * + * All sensitive security actions should call log() after the primary + * action completes so there is an immutable record of who did what. + */ 'use strict'; +/** + * Well-known audit action identifiers. + * @readonly + * @enum {string} + */ const ACTIONS = Object.freeze({ FIREWALL_RULE_CREATE: 'firewall.rule.create', FIREWALL_RULE_DELETE: 'firewall.rule.delete', @@ -14,6 +25,16 @@ const ACTIONS = Object.freeze({ MAINTENANCE_RUN: 'maintenance.run', }); +/** + * Append an audit entry. Failures are swallowed so audit logging + * never breaks the primary action. + * + * @param {DatabaseService} db - Database service instance. + * @param {string} action - One of ACTIONS. + * @param {*} [detail] - Action detail payload. + * @param {*} [result] - Action result payload. + * @param {boolean} [userInitiated=false] - Whether the user triggered this. + */ function log(db, action, detail, result, userInitiated = false) { if (!db || !action) return; try { diff --git a/src/core/database.js b/src/core/database.js index c68e9d8..3febf1a 100644 --- a/src/core/database.js +++ b/src/core/database.js @@ -2,7 +2,17 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); +/** + * Local SQLite persistence for Soterios. + * + * Owns scan history, quarantine records, settings, alerts, audit log, + * network stats, maintenance runs, user blocklists, and the incremental + * scan cache. + */ class DatabaseService { + /** + * @param {string} dbPath - Absolute path to the SQLite database file. + */ constructor(dbPath) { // Ensure the directory exists const dir = path.dirname(dbPath); @@ -14,6 +24,9 @@ class DatabaseService { this.init(); } + /** + * Create tables and apply schema migrations. + */ init() { this.db.pragma('journal_mode = WAL'); // Better performance @@ -208,15 +221,43 @@ class DatabaseService { } // --- Scan History API --- + + /** + * Record a scan execution summary. + * @param {string} scanType - One of 'quick', 'full', 'custom', 'folderwatch'. + * @param {number} filesScanned - Total files examined. + * @param {number} threatsFound - Threats detected. + * @param {number} durationMs - Wall-clock duration in milliseconds. + * @returns {Database.RunResult} Insert result. + */ logScan(scanType, filesScanned, threatsFound, durationMs) { const stmt = this.db.prepare('INSERT INTO scan_history (scan_type, files_scanned, threats_found, duration_ms) VALUES (?, ?, ?, ?)'); return stmt.run(scanType, filesScanned, threatsFound, durationMs); } + /** + * Retrieve recent scan history entries. + * @param {number} [limit=10] - Maximum rows to return. + * @returns {Array} Scan history rows. + */ getScanHistory(limit = 10) { return this.db.prepare('SELECT * FROM scan_history ORDER BY timestamp DESC LIMIT ?').all(limit); } + /** + * Persist a full scan report (JSON + HTML). + * @param {Object} report - Scan report payload. + * @param {string} report.scanType - Scan type identifier. + * @param {string} report.status - Final scan status. + * @param {string[]} report.targetPaths - Paths that were scanned. + * @param {number} report.filesScanned - Files examined. + * @param {number} report.threatsFound - Threats detected. + * @param {number} report.durationMs - Duration in milliseconds. + * @param {string} [report.jsonPath] - Optional JSON report path. + * @param {string} [report.htmlPath] - Optional HTML report path. + * @param {Object} [report.details] - Arbitrary report metadata. + * @returns {Database.RunResult} Insert result. + */ addScanReport(report) { const stmt = this.db.prepare(` INSERT INTO scan_reports ( @@ -305,21 +346,151 @@ class DatabaseService { return stmt.run(status, id); } + /** + * Retrieve recent scan reports. + * @param {number} [limit=25] - Maximum rows to return. + * @returns {Array} Scan report rows with parsed JSON fields. + */ + getScanReports(limit = 25) { + return this.db.prepare('SELECT * FROM scan_reports ORDER BY timestamp DESC LIMIT ?').all(limit).map((row) => ({ + ...row, + target_paths: JSON.parse(row.target_paths || '[]'), + details: JSON.parse(row.details || '{}') + })); + } + + /** + * Get the most recent scan report. + * @returns {Object|null} Latest scan report row with parsed JSON fields. + */ + getLatestScanReport() { + const row = this.db.prepare('SELECT * FROM scan_reports ORDER BY timestamp DESC LIMIT 1').get(); + if (!row) return null; + return { + ...row, + target_paths: JSON.parse(row.target_paths || '[]'), + details: JSON.parse(row.details || '{}') + }; + } + + /** + * Get a single scan report by id. + * @param {number} id - Scan report primary key. + * @returns {Object|null} Scan report row with parsed JSON fields. + */ + getScanReport(id) { + const row = this.db.prepare('SELECT * FROM scan_reports WHERE id = ?').get(id); + if (!row) return null; + let target_paths = []; + let details = {}; + try { + target_paths = JSON.parse(row.target_paths || '[]'); + } catch (_) { + target_paths = []; + } + try { + details = JSON.parse(row.details || '{}'); + } catch (_) { + details = {}; + } + return { + ...row, + target_paths, + details + }; + } + + /** + * Delete a scan report and return the removed row. + * @param {number} id - Scan report primary key. + * @returns {Object|null} The deleted row, or null if not found. + */ + deleteScanReport(id) { + const row = this.db.prepare('SELECT * FROM scan_reports WHERE id = ?').get(id); + if (!row) return null; + this.db.prepare('DELETE FROM scan_reports WHERE id = ?').run(id); + return row; + } + + // --- Quarantine API --- + + /** + * Insert a quarantine record. + * @param {Object} record - Quarantine record fields. + * @param {string} record.original_path - Original file path. + * @param {string} record.quarantine_path - Encrypted quarantine file path. + * @param {string} record.hash - File hash. + * @param {string} record.engine - Detection engine name. + * @param {string} record.threat_name - Threat identifier. + * @param {string} [record.reason] - Optional reason string. + * @returns {Database.RunResult} Insert result. + */ + addQuarantineRecord(record) { + const stmt = this.db.prepare(` + INSERT INTO quarantine (original_path, quarantine_path, hash, engine, threat_name, reason) + VALUES (@originalPath, @quarantinePath, @hash, @engine, @threatName, @reason) + `); + return stmt.run(record); + } + + /** + * List active quarantine entries. + * @returns {Array} Quarantine rows with status 'quarantined'. + */ + getQuarantineList() { + return this.db.prepare("SELECT * FROM quarantine WHERE status = 'quarantined' ORDER BY date_quarantined DESC").all(); + } + + /** + * Update the status of a quarantine record. + * @param {number} id - Quarantine row id. + * @param {string} status - New status ('quarantined', 'restored', 'deleted'). + * @returns {Database.RunResult} Update result. + */ + updateQuarantineStatus(id, status) { + const stmt = this.db.prepare('UPDATE quarantine SET status = ? WHERE id = ?'); + return stmt.run(status, id); + } + // --- Alerts API --- + + /** + * Create an alert entry. + * @param {string} severity - Alert severity level. + * @param {string} message - Alert message. + * @returns {Database.RunResult} Insert result. + */ addAlert(severity, message) { const stmt = this.db.prepare('INSERT INTO alerts (severity, message) VALUES (?, ?)'); return stmt.run(severity, message); } + /** + * Get unread alerts. + * @returns {Array} Alert rows where is_read = 0. + */ getUnreadAlerts() { return this.db.prepare('SELECT * FROM alerts WHERE is_read = 0 ORDER BY timestamp DESC').all(); } + /** + * Mark an alert as read. + * @param {number} id - Alert primary key. + * @returns {Database.RunResult} Update result. + */ markAlertRead(id) { const stmt = this.db.prepare('UPDATE alerts SET is_read = 1 WHERE id = ?'); return stmt.run(id); } + /** + * Record a maintenance run. + * @param {Object} options + * @param {string} [options.startedAt] - ISO timestamp. + * @param {Array} [options.results] - Script result objects. + * @param {boolean} [options.dryRunCleanup] - Whether this was a dry run. + * @returns {Database.RunResult} Insert result. + */ addMaintenanceRun({ startedAt, results, dryRunCleanup = false }) { const okCount = (results || []).filter((r) => r.ok).length; const stmt = this.db.prepare(` @@ -335,6 +506,11 @@ class DatabaseService { }); } + /** + * Get recent maintenance run history. + * @param {number} [limit=25] - Maximum rows. + * @returns {Array} Maintenance run rows with parsed results. + */ getMaintenanceHistory(limit = 25) { return this.db.prepare(` SELECT id, timestamp, started_at, ok_count, total_count, dry_run, results_json @@ -352,6 +528,11 @@ class DatabaseService { })); } + /** + * Prune old maintenance runs, keeping the most recent entries. + * @param {number} [keepCount=100] - Number of recent runs to retain. + * @returns {Database.RunResult} Delete result. + */ pruneMaintenanceRuns(keepCount = 100) { const count = this.db.prepare('SELECT COUNT(*) AS total FROM maintenance_runs').get().total; if (count <= keepCount) return { changes: 0 }; @@ -364,40 +545,87 @@ class DatabaseService { `).run(deleteCount); } + /** + * Mark a warning as ignored. + * @param {Object} warning - Warning record. + * @param {string} warning.id - Stable warning identifier. + * @param {string} warning.title - Warning title. + * @param {string} warning.detail - Warning detail text. + * @returns {Database.RunResult} Insert-or-replace result. + */ ignoreWarning(warning) { const stmt = this.db.prepare('INSERT OR REPLACE INTO ignored_warnings (id, title, detail) VALUES (@id, @title, @detail)'); return stmt.run(warning); } + /** + * Remove a warning from the ignored list. + * @param {string} id - Warning identifier. + * @returns {Database.RunResult} Delete result. + */ unignoreWarning(id) { return this.db.prepare('DELETE FROM ignored_warnings WHERE id = ?').run(id); } + /** + * Get all ignored warnings. + * @returns {Array} Ignored warning rows. + */ getIgnoredWarnings() { return this.db.prepare('SELECT * FROM ignored_warnings ORDER BY ignored_at DESC').all(); } + /** + * Check whether a warning id is currently ignored. + * @param {string} id - Warning identifier. + * @returns {boolean} True if the warning is ignored. + */ isWarningIgnored(id) { return !!this.db.prepare('SELECT id FROM ignored_warnings WHERE id = ?').get(id); } // --- Settings API --- + + /** + * Read a setting value. + * @param {string} key - Setting key. + * @param {*} [defaultValue=null] - Fallback when the key is missing. + * @returns {*} Parsed setting value, or defaultValue. + */ getSetting(key, defaultValue = null) { const stmt = this.db.prepare('SELECT value FROM settings WHERE key = ?'); const row = stmt.get(key); return row ? JSON.parse(row.value) : defaultValue; } + /** + * Write a setting value. + * @param {string} key - Setting key. + * @param {*} value - Setting value (will be JSON-serialized). + * @returns {Database.RunResult} Upsert result. + */ setSetting(key, value) { const stmt = this.db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); return stmt.run(key, JSON.stringify(value)); } // --- Network Blocklist Cache API --- + + /** + * Get cached blocklist data by source name. + * @param {string} source - Blocklist source identifier. + * @returns {Object|null} Cached blocklist row, or null. + */ getBlocklistCache(source) { return this.db.prepare('SELECT * FROM network_blocklist_cache WHERE source = ?').get(source) || null; } + /** + * Upsert blocklist cache data. + * @param {string} source - Blocklist source identifier. + * @param {string} rawData - Raw blocklist payload. + * @returns {Database.RunResult} Upsert result. + */ setBlocklistCache(source, rawData) { const stmt = this.db.prepare(` INSERT INTO network_blocklist_cache (source, raw_data, fetched_at) @@ -410,10 +638,22 @@ class DatabaseService { } // --- Network Geo Cache API --- + + /** + * Get cached geo data for an IP. + * @param {string} ip - IP address. + * @returns {Object|null} Cached geo row, or null. + */ getGeoCache(ip) { return this.db.prepare('SELECT * FROM network_geo_cache WHERE ip = ?').get(ip) || null; } + /** + * Upsert geo cache data. + * @param {string} ip - IP address. + * @param {string} rawData - Raw geo payload. + * @returns {Database.RunResult} Upsert result. + */ setGeoCache(ip, rawData) { const stmt = this.db.prepare(` INSERT INTO network_geo_cache (ip, raw_data, fetched_at) @@ -425,6 +665,11 @@ class DatabaseService { return stmt.run({ ip, rawData }); } + /** + * Get a reputation hash verdict. + * @param {string} hash - SHA-256 or similar hash. + * @returns {Object|null} Verdict object, or null. + */ getReputationHash(hash) { const row = this.db.prepare(` SELECT hash, verdict, source, note, added_at @@ -440,6 +685,15 @@ class DatabaseService { }; } + /** + * Upsert a reputation hash record. + * @param {Object} record - Reputation record. + * @param {string} record.hash - Hash identifier. + * @param {string} record.verdict - 'safe' or 'malicious'. + * @param {string} [record.source] - Source of the verdict. + * @param {string} [record.note] - Optional note. + * @returns {Database.RunResult} Upsert result. + */ upsertReputationHash(record) { const stmt = this.db.prepare(` INSERT INTO reputation_hashes (hash, verdict, source, note, added_at) @@ -453,11 +707,21 @@ class DatabaseService { return stmt.run(record); } + /** + * Remove a reputation hash record. + * @param {string} hash - Hash identifier. + * @returns {boolean} True if a row was deleted. + */ deleteReputationHash(hash) { const result = this.db.prepare('DELETE FROM reputation_hashes WHERE hash = ?').run(hash); return result.changes > 0; } + /** + * List stored reputation hashes. + * @param {number} [limit=500] - Maximum rows. + * @returns {Array} Reputation hash rows. + */ listReputationHashes(limit = 500) { return this.db.prepare(` SELECT hash, verdict, source, note, added_at @@ -468,6 +732,15 @@ class DatabaseService { } // --- Network stats history --- + + /** + * Record a network throughput sample. + * @param {string} iface - Network interface name. + * @param {number} rxSec - Received bytes per second. + * @param {number} txSec - Transmitted bytes per second. + * @param {string} [recordedAt] - ISO timestamp; defaults to now. + * @returns {Database.RunResult} Insert result. + */ addNetworkStatsSample(iface, rxSec, txSec, recordedAt = new Date().toISOString()) { return this.db.prepare(` INSERT INTO network_stats (recorded_at, iface, rx_sec, tx_sec) @@ -475,6 +748,12 @@ class DatabaseService { `).run(recordedAt, iface, rxSec, txSec); } + /** + * Get network stats history. + * @param {number} [hours=24] - Lookback window in hours. + * @param {string|null} [iface=null] - Optional interface filter. + * @returns {Array} Network stat rows. + */ getNetworkStatsHistory(hours = 24, iface = null) { const since = new Date(Date.now() - Number(hours) * 3600 * 1000).toISOString(); if (iface) { @@ -493,12 +772,25 @@ class DatabaseService { `).all(since); } + /** + * Delete network stats older than the retention window. + * @param {number} [retentionDays=7] - Retention window in days. + * @returns {Database.RunResult} Delete result. + */ pruneNetworkStats(retentionDays = 7) { const cutoff = new Date(Date.now() - Number(retentionDays) * 86400 * 1000).toISOString(); return this.db.prepare('DELETE FROM network_stats WHERE recorded_at < ?').run(cutoff); } // --- Alerts API --- + + /** + * Get alerts with optional filters. + * @param {Object} [options={}] - Query options. + * @param {number} [options.limit=50] - Maximum rows. + * @param {boolean} [options.unreadOnly=false] - Only unread alerts. + * @returns {Array} Alert rows. + */ getAlerts(options = {}) { const limit = Math.min(500, Number(options.limit) || 50); const unreadOnly = !!options.unreadOnly; @@ -508,6 +800,10 @@ class DatabaseService { return this.db.prepare(sql).all(limit); } + /** + * Get alert counts. + * @returns {{ total: number, unread: number }} Alert counts. + */ getAlertCounts() { const total = this.db.prepare('SELECT COUNT(*) AS c FROM alerts').get().c; const unread = this.db.prepare('SELECT COUNT(*) AS c FROM alerts WHERE is_read = 0').get().c; @@ -515,6 +811,16 @@ class DatabaseService { } // --- Audit Log API --- + + /** + * Append an audit log entry. + * @param {Object} entry + * @param {string} entry.action - Action identifier. + * @param {string} [entry.detail] - Optional detail text. + * @param {string} [entry.result] - Optional result text. + * @param {boolean} [entry.userInitiated=false] - Whether the user triggered this. + * @returns {Database.RunResult} Insert result. + */ addAuditEntry({ action, detail, result, userInitiated = false }) { const stmt = this.db.prepare(` INSERT INTO audit_log (action, detail, result, user_initiated) @@ -523,11 +829,24 @@ class DatabaseService { return stmt.run(action, detail, result, userInitiated ? 1 : 0); } + /** + * Get recent audit log entries. + * @param {number} [limit=100] - Maximum rows. + * @returns {Array} Audit log rows. + */ getAuditLog(limit = 100) { return this.db.prepare('SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?').all(limit); } // --- User Blocklist API --- + + /** + * Add an IP blocklist entry. + * @param {Object} entry + * @param {string} entry.ip - IP address or CIDR. + * @param {string} [entry.reason] - Optional reason. + * @returns {Database.RunResult} Insert result. + */ addUserBlocklistEntry(entry) { const stmt = this.db.prepare(` INSERT INTO user_blocklist (ip, reason) VALUES (@ip, @reason) @@ -535,19 +854,40 @@ class DatabaseService { return stmt.run({ ip: entry.ip, reason: entry.reason || null }); } + /** + * Remove an IP blocklist entry. + * @param {number} id - Blocklist row id. + * @returns {Database.RunResult} Delete result. + */ removeUserBlocklistEntry(id) { return this.db.prepare('DELETE FROM user_blocklist WHERE id = ?').run(id); } + /** + * Get all IP blocklist entries. + * @returns {Array} Blocklist rows. + */ getUserBlocklist() { return this.db.prepare('SELECT * FROM user_blocklist ORDER BY added_at DESC').all(); } + /** + * Clear all IP blocklist entries. + * @returns {Database.RunResult} Delete result. + */ clearUserBlocklist() { return this.db.prepare('DELETE FROM user_blocklist').run(); } // --- User Domain Blocklist API --- + + /** + * Add a domain blocklist entry. + * @param {Object} entry + * @param {string} entry.domain - Domain name. + * @param {string} [entry.reason] - Optional reason. + * @returns {Database.RunResult} Insert result. + */ addUserDomainBlocklistEntry(entry) { const stmt = this.db.prepare(` INSERT INTO user_domain_blocklist (domain, reason) VALUES (@domain, @reason) @@ -555,19 +895,37 @@ class DatabaseService { return stmt.run({ domain: entry.domain, reason: entry.reason || null }); } + /** + * Remove a domain blocklist entry. + * @param {number} id - Blocklist row id. + * @returns {Database.RunResult} Delete result. + */ removeUserDomainBlocklistEntry(id) { return this.db.prepare('DELETE FROM user_domain_blocklist WHERE id = ?').run(id); } + /** + * Get all domain blocklist entries. + * @returns {Array} Domain blocklist rows. + */ getUserDomainBlocklist() { return this.db.prepare('SELECT * FROM user_domain_blocklist ORDER BY added_at DESC').all(); } + /** + * Clear all domain blocklist entries. + * @returns {Database.RunResult} Delete result. + */ clearUserDomainBlocklist() { return this.db.prepare('DELETE FROM user_domain_blocklist').run(); } // --- Settings Export --- + + /** + * Export all settings as a plain object. + * @returns {Object} Settings key/value map. + */ exportAllSettings() { const settings = {}; const rows = this.db.prepare('SELECT key, value FROM settings').all(); @@ -578,6 +936,10 @@ class DatabaseService { return settings; } + /** + * Export active quarantine state. + * @returns {Array} Quarantine rows with status 'quarantined'. + */ exportQuarantineState() { return this.db.prepare(` SELECT id, original_path, quarantine_path, hash, engine, @@ -587,6 +949,15 @@ class DatabaseService { } // --- Incremental Scan Cache --- + + /** + * Record or update a scanned file entry. + * @param {Object} meta + * @param {string} meta.path - Absolute file path. + * @param {number|null} meta.size - File size in bytes. + * @param {string|null} meta.modifiedAt - ISO mtime string. + * @returns {Database.RunResult} Upsert result. + */ recordScannedFile({ path, size, modifiedAt }) { const stmt = this.db.prepare(` INSERT INTO scanned_files (path, size, modified_at) @@ -599,19 +970,33 @@ class DatabaseService { return stmt.run({ path, size: size || null, modifiedAt: modifiedAt || null }); } - getFilesToSkip(paths) { - if (!paths || !paths.length) return new Set(); - const placeholders = paths.map(() => '?').join(','); + /** + * Get files that can be skipped because their cached metadata matches. + * @param {Array<{ path: string, size: number|null, modifiedAt: string|null }>} fileMetadatas - Current file metadata objects. + * @returns {Set} Paths that can be skipped. + */ + getFilesToSkip(fileMetadatas) { + if (!Array.isArray(fileMetadatas) || fileMetadatas.length === 0) return new Set(); + const map = new Map(fileMetadatas.map((m) => [m.path, m])); + const placeholders = Array.from(map.keys()).map(() => '?').join(','); const rows = this.db.prepare(` - SELECT path, modified_at FROM scanned_files WHERE path IN (${placeholders}) - `).all(...paths); + SELECT path, size, modified_at FROM scanned_files WHERE path IN (${placeholders}) + `).all(...map.keys()); const skip = new Set(); for (const row of rows) { - skip.add(row.path); + const meta = map.get(row.path); + if (meta && meta.size != null && meta.modifiedAt != null && row.size === meta.size && row.modified_at === meta.modifiedAt) { + skip.add(row.path); + } } return skip; } + /** + * Prune old scanned-file cache entries. + * @param {number} [olderThanDays=30] - Delete entries older than this many days. + * @returns {Database.RunResult} Delete result. + */ pruneScannedFiles(olderThanDays = 30) { const cutoff = new Date(Date.now() - Number(olderThanDays) * 86400 * 1000).toISOString(); return this.db.prepare('DELETE FROM scanned_files WHERE last_scanned_at < ?').run(cutoff); diff --git a/src/core/eventBus.js b/src/core/eventBus.js index a5d0051..43407be 100644 --- a/src/core/eventBus.js +++ b/src/core/eventBus.js @@ -1,10 +1,29 @@ +/** + * In-process event bus used to decouple scanners, UI progress updates, + * tray summaries, and background engines. + */ class EventBus { + /** @type {Map>} */ constructor() { this._listeners = new Map(); } + + /** + * Subscribe to an event. + * @param {string} eventName + * @param {Function} handler + * @returns {() => void} Unsubscribe function. + */ on(eventName, handler) { if (!this._listeners.has(eventName)) this._listeners.set(eventName, new Set()); this._listeners.get(eventName).add(handler); return () => this.off(eventName, handler); } + + /** + * Subscribe to an event once. + * @param {string} eventName + * @param {Function} handler + * @returns {() => void} Unsubscribe function. + */ once(eventName, handler) { const wrapper = (payload) => { this.off(eventName, wrapper); @@ -13,14 +32,31 @@ class EventBus { this.on(eventName, wrapper); return () => this.off(eventName, wrapper); } + + /** + * Unsubscribe a handler from an event. + * @param {string} eventName + * @param {Function} handler + */ off(eventName, handler) { if (!this._listeners.has(eventName)) return; this._listeners.get(eventName).delete(handler); } + + /** + * Remove all listeners for an event, or all events if no name is given. + * @param {string} [eventName] + */ removeAllListeners(eventName) { if (eventName) this._listeners.delete(eventName); else this._listeners.clear(); } + + /** + * Emit an event to all subscribers. + * @param {string} eventName + * @param {*} payload + */ emit(eventName, payload) { if (!this._listeners.has(eventName)) return; for (const handler of this._listeners.get(eventName)) { diff --git a/src/core/featureFlags.js b/src/core/featureFlags.js index 3a2adc1..30614a0 100644 --- a/src/core/featureFlags.js +++ b/src/core/featureFlags.js @@ -1,32 +1,27 @@ -// 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, - autoUpdates: true, -}); - -const FLAG_KEYS = Object.freeze(Object.keys(DEFAULT_FLAGS)); +/** + * 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. + */ +/** + * Check whether a flag key is known. + * @param {string} key + * @returns {boolean} + */ function isKnownFlag(key) { const logicalKey = key.startsWith('feature.') ? key.substring(8) : key; return Object.prototype.hasOwnProperty.call(DEFAULT_FLAGS, logicalKey); } -function getFlag(db, key, fallback) { + /** + * Get a boolean feature flag from the database or default. + * @param {object} db + * @param {string} key + * @param {*} [fallback] + * @returns {boolean} + */ + function getFlag(db, key, fallback) { if (!isKnownFlag(key)) { throw new Error(`Unknown feature flag: ${key}`); } @@ -39,7 +34,14 @@ function getFlag(db, key, fallback) { return Boolean(raw); } -function setFlag(db, key, value) { + /** + * Set a boolean feature flag in the database. + * @param {object} db + * @param {string} key + * @param {boolean} value + * @returns {boolean} + */ + function setFlag(db, key, value) { if (!isKnownFlag(key)) { throw new Error(`Unknown feature flag: ${key}`); } @@ -49,7 +51,11 @@ function setFlag(db, key, value) { return boolValue; } -function getDefaults() { + /** + * Get a shallow copy of the default feature flags. + * @returns {Object} + */ + function getDefaults() { return { ...DEFAULT_FLAGS }; } diff --git a/src/core/pluginLoader.js b/src/core/pluginLoader.js index 7d69f3a..f90fa46 100644 --- a/src/core/pluginLoader.js +++ b/src/core/pluginLoader.js @@ -4,6 +4,10 @@ const toolRegistry = require('./toolRegistry'); const TOOLS_DIR = path.join(__dirname, '..', 'tools'); +/** + * Load all tool modules from the tools directory into the tool registry. + * @returns {Promise} + */ async function loadAll() { const files = fs .readdirSync(TOOLS_DIR) diff --git a/src/core/scanProgress.js b/src/core/scanProgress.js index 1c9a09e..7045753 100644 --- a/src/core/scanProgress.js +++ b/src/core/scanProgress.js @@ -1,8 +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. +/** + * 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. + */ +/** + * Clamp a raw progress value to a safe 0-100 integer. + * @param {*} value - Raw progress value. + * @returns {number} Clamped integer percentage. + */ function clampProgress(value) { const n = Number(value); if (!Number.isFinite(n)) return 0; diff --git a/src/core/toolRegistry.js b/src/core/toolRegistry.js index 4e631df..57ab796 100644 --- a/src/core/toolRegistry.js +++ b/src/core/toolRegistry.js @@ -1,5 +1,23 @@ +/** + * Registry for local maintenance tools loaded from src/tools/. + * + * Tools are plain modules exporting an object with id, name, description, + * category, icon, and an async run(args, ctx) function. + */ const tools = new Map(); +/** + * Register a tool plugin. + * @param {Object} tool - Tool definition. + * @param {string} tool.id - Unique tool identifier. + * @param {string} tool.name - Human-readable name. + * @param {string} tool.description - Short description. + * @param {string} [tool.category] - Category slug. + * @param {string} [tool.icon] - Icon identifier. + * @param {boolean} [tool.stub] - If true, the tool is not yet implemented. + * @param {Function} tool.run - Async executor. + * @throws {Error} If tool is missing or has no id. + */ function register(tool) { if (!tool || !tool.id) { throw new Error('Tool plugin is missing a required "id" field'); @@ -10,12 +28,23 @@ function register(tool) { tools.set(tool.id, tool); } +/** + * List all registered tools. + * @returns {Array} Tool summary objects. + */ function list() { return Array.from(tools.values()).map(({ id, name, description, category, icon, stub }) => ({ id, name, description, category, icon, stub: !!stub })); } +/** + * Execute a registered tool. + * @param {string} toolId - Tool identifier. + * @param {Object} [args={}] - Tool arguments. + * @param {Object} [ctx={}] - Execution context (db, eventBus, mainWindow, etc.). + * @returns {Promise<{ ok: boolean, data?: *, error?: string }>} Execution result. + */ async function run(toolId, args, ctx) { const tool = tools.get(toolId); if (!tool) return { ok: false, error: `Unknown tool: ${toolId}` }; diff --git a/src/main/ipc/_shared.js b/src/main/ipc/_shared.js index ce14206..2e259a5 100644 --- a/src/main/ipc/_shared.js +++ b/src/main/ipc/_shared.js @@ -1,7 +1,18 @@ +/** + * Shared IPC helper utilities. + */ + const https = require('https'); const MAX_API_BODY_BYTES = 1 * 1024 * 1024; // 1 MB +/** + * Make an HTTP GET request and return the response body as text. + * @param {string} url + * @param {Object} [options] + * @param {Object} [options.headers] + * @returns {Promise<{statusCode:number, body:string}>} + */ function requestText(url, options = {}) { return new Promise((resolve, reject) => { const req = https.request(url, { diff --git a/src/main/ipc/firewall.js b/src/main/ipc/firewall.js index c2b3ccc..b19f9c8 100644 --- a/src/main/ipc/firewall.js +++ b/src/main/ipc/firewall.js @@ -10,16 +10,33 @@ const { const VALID_FIREWALL_PROFILES = ['Domain', 'Private', 'Public']; +/** + * Check whether a string is a valid firewall profile name. + * @param {string} name + * @returns {boolean} + */ function isValidFirewallProfile(name) { return typeof name === 'string' && VALID_FIREWALL_PROFILES.includes(name); } +/** + * Validate an IP address string (IPv4 or IPv6). + * @param {string} ip + * @returns {boolean} + */ 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(':')); } +/** + * Register firewall-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.db + * @param {object} services.firewallManager + */ function register(mainWindow, { db, firewallManager }) { ipcMain.handle('firewall:status', async () => { return firewallManager.getStatus(); diff --git a/src/main/ipc/network.js b/src/main/ipc/network.js index c8dbfcf..f6f9678 100644 --- a/src/main/ipc/network.js +++ b/src/main/ipc/network.js @@ -8,6 +8,11 @@ const { InvalidInputError } = require('../../utils/errors'); const featureFlags = require('../../core/featureFlags'); +/** + * Validate an IPv4 address string. + * @param {string} ip + * @returns {boolean} + */ 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})$/); @@ -15,6 +20,11 @@ function isValidIPv4(ip) { return m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255); } +/** + * Execute a raw PowerShell command and return stdout. + * @param {string} command + * @returns {Promise} + */ async function runPowerShellRaw(command) { const { stdout } = await execFilePromise( 'powershell.exe', @@ -24,7 +34,16 @@ async function runPowerShellRaw(command) { return stdout; } -async function measureConnectionBandwidth({ localAddress, localPort, remoteAddress, remotePort }) { + /** + * Measure bandwidth for a specific TCP connection. + * @param {Object} params + * @param {string} params.localAddress + * @param {number} params.localPort + * @param {string} params.remoteAddress + * @param {number} params.remotePort + * @returns {Promise} + */ + async function measureConnectionBandwidth({ localAddress, localPort, remoteAddress, remotePort }) { if (!isValidIPv4(localAddress) || !isValidIPv4(remoteAddress)) { throw new InvalidInputError('Per-connection bandwidth currently only supports IPv4 TCP connections.'); } @@ -138,6 +157,19 @@ Write-Output "OK|$outBitsPerSec|$inBitsPerSec" }; } +/** + * Register network-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.db + * @param {object} services.eventBus + * @param {object} services.networkMonitor + * @param {object} services.networkEnricher + * @param {object} services.networkAlertMonitor + * @param {object} services.geoLocationService + * @param {Function} services.startNetworkStatsTimer + * @param {Function} services.stopNetworkStatsTimer + */ function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, networkAlertMonitor, geoLocationService, startNetworkStatsTimer, stopNetworkStatsTimer }) { // -- Network suspicious-connection alerts -- ipcMain.handle('network-alerts:status', async () => { @@ -203,7 +235,7 @@ function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, n { name: 'localPort', type: 'number', required: true, min: 0, max: 65535 }, { name: 'remoteAddress', type: 'string', required: true }, { name: 'remotePort', type: 'number', required: true, min: 0, max: 65535 }, - ], [spec]); + ], spec); return measureConnectionBandwidth(spec || {}); }); @@ -216,7 +248,7 @@ function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, n validateArgs([ { name: 'ip', type: 'string', required: true }, { name: 'reason', type: 'string', required: false }, - ], [entry]); + ], entry); return db.addUserBlocklistEntry(entry); }); @@ -240,7 +272,7 @@ function register(mainWindow, { db, eventBus, networkMonitor, networkEnricher, n validateArgs([ { name: 'domain', type: 'string', required: true }, { name: 'reason', type: 'string', required: false }, - ], [entry]); + ], entry); return db.addUserDomainBlocklistEntry(entry); }); diff --git a/src/main/ipc/process.js b/src/main/ipc/process.js index 5604bb4..8034e85 100644 --- a/src/main/ipc/process.js +++ b/src/main/ipc/process.js @@ -1,6 +1,12 @@ const { ipcMain } = require('electron'); const { validateArgs } = require('./validate'); +/** + * Register process-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.processInspector + */ function register(mainWindow, { processInspector }) { ipcMain.handle('process:list', async () => { return processInspector.getProcesses(); diff --git a/src/main/ipc/quarantine.js b/src/main/ipc/quarantine.js index 139373a..5d05079 100644 --- a/src/main/ipc/quarantine.js +++ b/src/main/ipc/quarantine.js @@ -1,6 +1,12 @@ const { ipcMain } = require('electron'); const { validateArgs } = require('./validate'); +/** + * Register quarantine-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.quarantineManager + */ function register(mainWindow, { quarantineManager }) { ipcMain.handle('quarantine:restore', async (_event, id) => { validateArgs([ diff --git a/src/main/ipc/scan.js b/src/main/ipc/scan.js index d434552..fb04a3f 100644 --- a/src/main/ipc/scan.js +++ b/src/main/ipc/scan.js @@ -12,6 +12,16 @@ const DEFAULT_SCHEDULE = { lastRun: null, }; +/** + * Register scan-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.db + * @param {object} services.eventBus + * @param {object} services.clamEngine + * @param {object} services.scanEngine + * @param {object} services.reputationEngine + */ function register(mainWindow, { db, eventBus, clamEngine, scanEngine, reputationEngine }) { // -- Scanning Engine -- ipcMain.handle('scan:status', () => { diff --git a/src/main/ipc/system.js b/src/main/ipc/system.js index ece566d..fe80cd9 100644 --- a/src/main/ipc/system.js +++ b/src/main/ipc/system.js @@ -25,6 +25,10 @@ const { requestText } = require('./_shared'); const featureFlags = require('../../core/featureFlags'); const { AppError, PermissionError } = require('../../utils/errors'); +/** + * Delete a file if it exists, swallowing non-critical filesystem errors. + * @param {string} filePath + */ function deleteFileIfSafe(filePath) { if (!filePath) return; try { @@ -34,6 +38,25 @@ function deleteFileIfSafe(filePath) { } } +/** + * Register system-related IPC handlers. + * @param {BrowserWindow} mainWindow + * @param {Object} services + * @param {object} services.db + * @param {object} services.eventBus + * @param {object} services.toolRegistry + * @param {object} services.maintenanceScheduler + * @param {object} services.firewallManager + * @param {object} services.networkMonitor + * @param {object} services.geoLocationService + * @param {object} services.systemAudit + * @param {object} services.realtimeWatcher + * @param {object} services.folderWatcher + * @param {Function} services.startNetworkStatsTimer + * @param {Function} services.stopNetworkStatsTimer + * @param {object} services.emergencyLockdown + * @param {boolean} services.isActuallyAdmin + */ function register(mainWindow, { db, eventBus, @@ -146,7 +169,7 @@ function register(mainWindow, { { name: 'detail', type: 'string', required: false }, { name: 'result', type: 'string', required: false }, { name: 'userInitiated', type: 'boolean', required: false }, - ], [entry]); + ], entry); return db.addAuditEntry({ action: entry.action, detail: entry.detail || null, @@ -159,7 +182,7 @@ function register(mainWindow, { validateArgs([ { name: 'limit', type: 'number', required: false, min: 1, max: 500 }, { name: 'unreadOnly', type: 'boolean', required: false }, - ], [options]); + ], options); return db.getAlerts(options); }); diff --git a/src/main/ipcHandlers.js b/src/main/ipcHandlers.js index 5e4b85b..232a03c 100644 --- a/src/main/ipcHandlers.js +++ b/src/main/ipcHandlers.js @@ -5,6 +5,15 @@ const { register: registerFirewall } = require('./ipc/firewall'); const { register: registerNetwork } = require('./ipc/network'); const { register: registerSystem } = require('./ipc/system'); +/** + * Register all IPC handlers for the main window. + * + * Each domain (scan, quarantine, process, firewall, network, system) + * gets its own service slice to keep handlers decoupled. + * + * @param {BrowserWindow} mainWindow + * @param {Object} services + */ function registerIpcHandlers(mainWindow, services) { const servicesForScan = { db: services.db, diff --git a/src/main/lifecycle.js b/src/main/lifecycle.js index 6fc8b51..4fe7e5a 100644 --- a/src/main/lifecycle.js +++ b/src/main/lifecycle.js @@ -1,5 +1,16 @@ 'use strict'; +/** + * Application lifecycle and startup orchestration. + * + * Handles: + * - Startup locale/theme detection + * - IPC handler registration + * - Service wiring + * - Tray initialization + * - Updater initialization + * - Background engines (maintenance scheduler, folder watcher, etc.) + */ const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -16,12 +27,24 @@ const { initTrayDashboard } = require('./trayDashboard'); const { registerIpcHandlers } = require('./ipcHandlers'); const { MaintenanceScheduler } = require('./maintenanceScheduler'); const windowManager = require('./windowManager'); - +const { InvalidInputError } = require('../utils/errors'); + +/** + * Log a line through the centralized logger. + * @param {string} level + * @param {string} message + * @param {Object} [meta] + */ function logLine(level, message, meta) { const fn = logger[level] || logger.info; fn(message, meta || undefined); } +/** + * Peek the saved UI language from the database without opening a full service. + * @param {string} dbPath + * @returns {string} + */ function peekUiLanguage(dbPath) { try { if (!fs.existsSync(dbPath)) return 'en'; @@ -39,6 +62,11 @@ function peekUiLanguage(dbPath) { } } +/** + * Peek the saved UI theme from the database without opening a full service. + * @param {string} dbPath + * @returns {string} + */ function peekUiTheme(dbPath) { try { if (!fs.existsSync(dbPath)) return 'dark'; @@ -56,6 +84,12 @@ function peekUiTheme(dbPath) { } } +/** + * Resolve the effective locale from DB or fallback. + * @param {object} dbRef + * @param {string} startupLocale + * @returns {string} + */ function getLocale(dbRef, startupLocale) { if (dbRef) { try { @@ -68,10 +102,68 @@ function getLocale(dbRef, startupLocale) { return startupLocale; } +/** + * Translate a key using the current startup locale. + * @param {string} key + * @param {Object} [vars] + * @returns {string} + */ function t(key, vars) { return i18n.t(key, getLocale(windowManager.dbRef, windowManager.startupLocale), vars); } +/** + * Validate a startup persistence item. + * Rejects path separators, control characters, and traversal attempts. + * @param {Object} item + * @param {string} item.source + * @param {string} item.value + * @throws {InvalidInputError} + */ +function validateStartupItem(item) { + if (!item || typeof item !== 'object') { + throw new InvalidInputError('Invalid startup item'); + } + if (!item.source || !['registry', 'startup-folder'].includes(item.source)) { + throw new InvalidInputError('Invalid startup item source'); + } + if (!item.name || typeof item.name !== 'string' || item.name.length === 0 || item.name.length > 256) { + throw new InvalidInputError('Invalid startup item name'); + } + // Reject path separators and control characters in registry value names / filenames. + if (/[\\/:*?"<>|\x00-\x1f]/.test(item.name)) { + throw new InvalidInputError('Startup item name contains invalid characters'); + } + if (item.source === 'registry') { + if (!item.command || typeof item.command !== 'string') { + throw new InvalidInputError('Invalid registry command'); + } + } else if (item.source === 'startup-folder') { + if (!item.path || typeof item.path !== 'string') { + throw new InvalidInputError('Invalid startup item path'); + } + const appData = process.env.APPDATA || ''; + const programData = process.env.ProgramData || ''; + const userStartup = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); + const allStartup = path.join(programData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'); + const expectedDir = item.scope === 'user' ? userStartup : allStartup; + const resolved = path.resolve(item.path); + if (resolved !== expectedDir && !resolved.startsWith(expectedDir + path.sep)) { + throw new InvalidInputError('Startup item path is outside the allowed directory'); + } + } +} + +/** + * Wire core services from the service registry. + * @param {object} db + * @param {object} eventBus + * @param {Object} [options] + * @param {string} [options.userDataPath] + * @param {string} [options.locale] + * @param {Function} [options.notify] + * @returns {Object} + */ function wireServices(db, eventBus, options = {}) { const notify = options.notify || (() => {}); const locale = options.locale || 'en'; @@ -83,6 +175,12 @@ function wireServices(db, eventBus, options = {}) { return services; } +/** + * Initialize auto-updater and broadcast status to windows. + * @param {Object} services + * @param {Object} [options] + * @param {Function} [options.notify] + */ function initUpdater(services, options = {}) { const notify = options.notify || (() => {}); updater.initAutoUpdater({ onNotify: (title, body, level) => notify(title, body, level) }); @@ -93,6 +191,15 @@ function initUpdater(services, options = {}) { }); } +/** + * Initialize the system tray dashboard. + * @param {Object} services + * @param {Object} [options] + * @param {BrowserWindow} [options.mainWindow] + * @param {Electron.App} [options.app] + * @param {object} [options.db] + * @param {Function} [options.notify] + */ function initTray(services, options = {}) { const { mainWindow, app } = options; const db = options.db; @@ -111,6 +218,11 @@ function initTray(services, options = {}) { } } +/** + * Register event-bus listeners for scan progress and completion. + * @param {Object} services + * @param {object} db + */ function registerProgressListeners(services, db) { const { mainWindow, eventBus } = services; if (!mainWindow || mainWindow.isDestroyed()) return; @@ -193,6 +305,11 @@ function registerProgressListeners(services, db) { }); } +/** + * Start background engines: ClamAV, realtime watcher, folder watcher, etc. + * @param {Object} services + * @param {object} db + */ async function startBackgroundEngines(services, db) { const { clamEngine, realtimeWatcher, folderWatcher, networkAlertMonitor, blocklistService, networkMonitor } = services; @@ -237,6 +354,16 @@ async function startBackgroundEngines(services, db) { } } +/** + * Start the application: wire services, initialize engines, create windows. + * @param {object} db + * @param {object} eventBus + * @param {Object} [options] + * @param {string} [options.userDataPath] + * @param {string} [options.startupLocale] + * @param {Function} [options.notify] + * @returns {Promise} Started services. + */ async function start(db, eventBus, options = {}) { const { userDataPath, notify } = options; const locale = getLocale(db, options.startupLocale || 'en'); @@ -411,6 +538,7 @@ async function start(db, eventBus, options = {}) { // Enable/disable a startup item ipcMain.handle('startup:toggle', async (_event, item, enable) => { try { + validateStartupItem(item); if (item.source === 'registry') { const hive = item.scope === 'HKLM' ? 'HKLM' : 'HKCU'; const key = `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`; @@ -481,17 +609,6 @@ async function start(db, eventBus, options = {}) { // blocking startup. (async () => { await startBackgroundEngines(services, db); - - const pruneTimer = setInterval(() => { - try { - db.pruneNetworkStats(7); - db.pruneMaintenanceRuns(100); - } catch (err) { - logLine('debug', 'Prune maintenance task failed', { error: err.message }); - } - }, 60 * 60_000); - if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); - services._pruneTimer = pruneTimer; })(); return services; diff --git a/src/main/main.js b/src/main/main.js index a5f0772..af294f1 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -1,5 +1,14 @@ 'use strict'; +/** + * Electron main-process entry point for Soterios. + * + * Responsibilities: + * - Configure Electron paths/command-line switches before app ready. + * - Enforce single-instance lock. + * - Start the lifecycle services and create the main window. + * - Register top-level app event handlers (second-instance, ready, window-all-closed, quit). + */ const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, screen } = require('electron'); const { execFileSync } = require('child_process'); const path = require('path'); @@ -116,7 +125,7 @@ app.whenReady().then(async () => { const eventBus = require('../core/eventBus'); - const services = lifecycle.start(db, eventBus, { + const services = await lifecycle.start(db, eventBus, { userDataPath: app.getPath('userData'), startupLocale, notify: (title, body, level) => windowManager.showNotification(lifecycle.t(title), lifecycle.t(body), level), @@ -127,6 +136,7 @@ app.whenReady().then(async () => { // splashWindow was created earlier via windowManager.createSplashWindow(); // do NOT overwrite it with services.splashWindow (which is undefined). windowManager.splashTimeoutId = services.splashTimeoutId; + windowManager.lifecycleRefs = services; // Renderer signals it has finished loading data; dismiss the splash. ipcMain.handle('app:ready', () => { @@ -158,14 +168,16 @@ process.on('unhandledRejection', (err) => { }); app.on('before-quit', () => { - if (windowManager.lifecycleRefs) { - windowManager.lifecycleRefs.maintenanceScheduler?.stop(); - windowManager.lifecycleRefs.trayController?.dispose(); - if (windowManager.lifecycleRefs.networkStatsTimer) clearInterval(windowManager.lifecycleRefs.networkStatsTimer); - if (windowManager.lifecycleRefs.pruneTimer) clearInterval(windowManager.lifecycleRefs.pruneTimer); + const lifecycleRefs = windowManager.lifecycleRefs; + if (lifecycleRefs) { + lifecycleRefs.maintenanceScheduler?.stop(); + lifecycleRefs.trayController?.dispose(); + if (lifecycleRefs.networkStatsTimer) clearInterval(lifecycleRefs.networkStatsTimer); + if (lifecycleRefs.pruneTimer) clearInterval(lifecycleRefs.pruneTimer); } try { - if (windowManager.dbRef?.db && typeof windowManager.dbRef.db.close === 'function') windowManager.dbRef.db.close(); + const dbRef = windowManager.dbRef; + if (dbRef?.db && typeof dbRef.db.close === 'function') dbRef.db.close(); } catch (err) { lifecycle.logLine('debug', 'Database close failed', { error: err.message }); } diff --git a/src/main/trayDashboard.js b/src/main/trayDashboard.js index 9efc17a..d59dedb 100644 --- a/src/main/trayDashboard.js +++ b/src/main/trayDashboard.js @@ -1,3 +1,8 @@ +/** + * System tray icon and dashboard window. + * + * Shows a compact health-summary popup anchored to the tray icon. + */ 'use strict'; const { Tray, BrowserWindow, nativeImage, screen } = require('electron'); @@ -8,11 +13,20 @@ const TRAY_WIDTH = 320; const TRAY_HEIGHT = 220; const TRAY_MARGIN = 8; +/** + * Create the tray icon image. + * @returns {Electron.NativeImage} + */ function createTrayIcon() { const iconPath = path.join(__dirname, '../../assets/icon.ico'); return nativeImage.createFromPath(iconPath); } +/** + * Position the tray dashboard window near the tray icon. + * @param {Electron.Tray} tray + * @param {BrowserWindow} trayWindow + */ function positionTrayWindow(tray, trayWindow) { const trayBounds = tray.getBounds(); const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y }); @@ -26,6 +40,14 @@ function positionTrayWindow(tray, trayWindow) { trayWindow.setBounds({ x, y, width: effectiveWidth, height: effectiveHeight }, false); } +/** + * Initialize the tray dashboard. + * @param {Object} params + * @param {Electron.App} params.app + * @param {BrowserWindow} params.mainWindow + * @param {Function} params.getSummary + * @returns {Object|null} + */ function initTrayDashboard({ app, mainWindow, getSummary }) { let tray = null; let trayWindow = null; diff --git a/src/main/updater.js b/src/main/updater.js index 653d41d..9cf1978 100644 --- a/src/main/updater.js +++ b/src/main/updater.js @@ -1,3 +1,9 @@ +/** + * Electron auto-updater wrapper. + * + * Manages update state, forwards status to listeners, and exposes + * simple init/check/install helpers. + */ 'use strict'; const { app } = require('electron'); @@ -20,6 +26,10 @@ const state = { error: null }; +/** + * Merge a patch into the shared update state and notify listeners. + * @param {Object} patch + */ function setState(patch) { Object.assign(state, patch); for (const listener of setState._listeners) { @@ -101,6 +111,10 @@ async function checkForUpdates() { return { ...state }; } +/** + * Quit and install the downloaded update. + * @returns {Promise<{success:boolean, error?:string}>} + */ function quitAndInstall() { if (!autoUpdater || state.status !== 'ready') { return { success: false, error: 'No downloaded update is ready to install.' }; @@ -109,14 +123,23 @@ function quitAndInstall() { return { success: true }; } +/** + * Get the current update status. + * @returns {Object} + */ function getUpdateStatus() { return { ...state }; } -function subscribe(listener) { - setState._listeners.add(listener); - return () => setState._listeners.delete(listener); -} + /** + * Subscribe to updater status changes. + * @param {Function} listener + * @returns {Function} Unsubscribe function. + */ + function subscribe(listener) { + setState._listeners.add(listener); + return () => setState._listeners.delete(listener); + } module.exports = { initAutoUpdater, diff --git a/src/main/windowManager.js b/src/main/windowManager.js index b374e93..7976229 100644 --- a/src/main/windowManager.js +++ b/src/main/windowManager.js @@ -1,5 +1,12 @@ 'use strict'; +/** + * Electron window creation, toast management, and screenshot capture. + * + * Maintains module-level references to the main window, splash window, + * and lifecycle refs so other main-process modules can access them + * without tight coupling. + */ const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, screen } = require('electron'); const path = require('path'); const fs = require('fs'); @@ -20,7 +27,18 @@ let t = (key, vars) => i18n.t(key, i18n.normalizeLocale(startupLocale), vars); let mainWindow = null; let splashWindow = null; let splashTimeoutId = null; +let lifecycleRefs = null; +/** + * Initialize module-level dependencies injected by lifecycle. + * @param {Object} deps + * @param {object} deps.dbRef + * @param {object} deps.featureFlags + * @param {string} deps.currentUiTheme + * @param {string} deps.startupLocale + * @param {Function} deps.logLine + * @param {Function} deps.t + */ function init({ dbRef: db, featureFlags: ff, currentUiTheme: theme, startupLocale: locale, logLine: ll, t: translator }) { dbRef = db; featureFlags = ff; @@ -46,10 +64,20 @@ const TOAST_ICONS = { threat: '' }; +/** + * Escape a string for safe insertion into toast HTML. + * @param {string} v + * @returns {string} + */ function escToastHtml(v) { return String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } +/** + * Read a PNG asset and return a base64 data URI. + * @param {string} relativePath - Path relative to the project root. + * @returns {string} + */ function readPngAsDataUri(relativePath) { try { const fullPath = path.join(__dirname, '../../', relativePath); @@ -60,16 +88,33 @@ function readPngAsDataUri(relativePath) { } } +/** + * Get the toast mark icon as a base64 data URI. + * @returns {string} + */ function getToastMarkDataUri() { if (!getToastMarkDataUri._cache) getToastMarkDataUri._cache = readPngAsDataUri('assets/toast-icon.png'); return getToastMarkDataUri._cache; } +/** + * Get the toast wordmark icon as a base64 data URI. + * @returns {string} + */ function getToastWordmarkDataUri() { if (!getToastWordmarkDataUri._cache) getToastWordmarkDataUri._cache = readPngAsDataUri('assets/toast-wordmark.png'); return getToastWordmarkDataUri._cache; } +/** + * Render the toast HTML payload. + * @param {string} title + * @param {string} body + * @param {'info'|'success'|'warn'|'danger'} [level] + * @param {string} themeName + * @param {string} [iconOverride] + * @returns {string} + */ function toastHtml(title, body, level, themeName, iconOverride = null) { const theme = TOAST_THEMES[themeName] || TOAST_THEMES.dark; const accent = theme.accents[level] || theme.accents.info; @@ -94,9 +139,9 @@ function toastHtml(title, body, level, themeName, iconOverride = null) { }); } -// Newest toast lands closest to the bottom margin; older ones already on -// screen get pushed upward above it, same stacking behavior as Windows' -// own Action Center toasts. +/** + * Reposition active toast windows in a bottom-right stack. + */ function repositionToasts() { const display = screen.getPrimaryDisplay(); const { x, y, width, height } = display.workArea; @@ -110,6 +155,13 @@ function repositionToasts() { } } +/** + * Show a toast notification. + * @param {string} title + * @param {string} body + * @param {'info'|'success'|'warn'|'danger'} [level] + * @param {string} [iconOverride] + */ function showNotification(title, body, level = 'info', iconOverride = null) { if (dbRef && featureFlags && !featureFlags.getFlag(dbRef, 'notificationsEnabled', true)) return; try { @@ -157,6 +209,11 @@ function showNotification(title, body, level = 'info', iconOverride = null) { } } +/** + * Create the splash window shown during app startup. + * @param {string} [themeName] + * @returns {BrowserWindow} + */ function createSplashWindow(themeName = 'dark') { const theme = resolveThemeName(themeName); splashWindow = new BrowserWindow({ @@ -186,12 +243,24 @@ function createSplashWindow(themeName = 'dark') { return splashWindow; } +/** + * Send progress data to the splash window. + * @param {BrowserWindow} splashWindow + * @param {number} pct + * @param {string} label + */ function sendSplashProgress(splashWindow, pct, label) { if (splashWindow && !splashWindow.isDestroyed()) { splashWindow.webContents.send('splash:progress', { pct, label }); } } +/** + * Dismiss the splash window and show the main window. + * @param {BrowserWindow} [mainWindowArg] + * @param {BrowserWindow} [splashWindowArg] + * @param {number} [splashTimeoutIdArg] + */ function dismissSplash(mainWindowArg, splashWindowArg, splashTimeoutIdArg) { const timeout = splashTimeoutIdArg ?? splashTimeoutId; if (timeout) { @@ -207,11 +276,19 @@ function dismissSplash(mainWindowArg, splashWindowArg, splashTimeoutIdArg) { } } +/** + * Create the app icon from the packaged asset. + * @returns {Electron.NativeImage} + */ function createIcon() { const iconPath = path.join(__dirname, '../../assets/icon.ico'); return nativeImage.createFromPath(iconPath); } +/** + * Create the main application window. + * @returns {{ mainWindow: BrowserWindow, splashTimeoutId: number }} + */ function createWindow() { mainWindow = new BrowserWindow({ width: 1280, @@ -255,6 +332,10 @@ function createWindow() { return { mainWindow, splashTimeoutId }; } +/** + * Build and apply the application menu. + * @param {BrowserWindow} mainWindow + */ function buildAppMenu(mainWindow) { const isMac = process.platform === 'darwin'; const aboutHandler = () => { @@ -291,10 +372,18 @@ function buildAppMenu(mainWindow) { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } +/** + * Check whether the app was launched in screenshot capture mode. + * @returns {boolean} + */ function isScreenshotCaptureMode() { return process.argv.includes('--screenshot-capture'); } +/** + * Parse screenshot capture config from argv. + * @returns {Object|null} + */ function getScreenshotConfig() { if (!isScreenshotCaptureMode()) return null; const pageArg = process.argv.find((arg) => arg.startsWith('--screenshot-page=')); @@ -306,11 +395,23 @@ function getScreenshotConfig() { return { page, outPath, runUninstaller: process.argv.includes('--screenshot-run-uninstaller') }; } +/** + * Log a fatal screenshot capture config error and exit. + * @param {string} message + */ function failScreenshotCapture(message) { logLine('error', message); app.exit(1); } +/** + * Schedule a screenshot capture after page load. + * @param {BrowserWindow} win + * @param {Object} config + * @param {string} config.page + * @param {string} config.outPath + * @param {boolean} [config.runUninstaller] + */ function scheduleScreenshotCapture(win, config) { win.webContents.once('did-finish-load', () => { dismissSplash(win, null, null); @@ -398,4 +499,18 @@ module.exports = { TOAST_GAP, TOAST_LIFETIME_MS, TOAST_ICONS, + get dbRef() { return dbRef; }, + get featureFlags() { return featureFlags; }, + get currentUiTheme() { return currentUiTheme; }, + get startupLocale() { return startupLocale; }, + get mainWindow() { return mainWindow; }, + get splashWindow() { return splashWindow; }, + get splashTimeoutId() { return splashTimeoutId; }, + get lifecycleRefs() { return lifecycleRefs; }, + set mainWindow(value) { mainWindow = value; }, + set splashWindow(value) { splashWindow = value; }, + set splashTimeoutId(value) { splashTimeoutId = value; }, + set lifecycleRefs(value) { lifecycleRefs = value; }, + get dbRefDirect() { return dbRef; }, + get mainWindowDirect() { return mainWindow; }, }; diff --git a/src/security/ClamAVEngine.js b/src/security/ClamAVEngine.js index c16893a..bc98e29 100644 --- a/src/security/ClamAVEngine.js +++ b/src/security/ClamAVEngine.js @@ -1,7 +1,17 @@ +/** + * Windows-specific ClamAV engine. + * + * Overrides binary names to `.exe` and keeps windows hidden. + */ const path = require('path'); const ClamAVEngineBase = require('./ClamAVEngineBase'); class ClamAVEngine extends ClamAVEngineBase { + /** + * @param {Object} [options] + * @param {string} [options.baseDir] + * @param {string} [options.dbDir] + */ constructor(options = {}) { super(options); } diff --git a/src/security/ClamAVEngineBase.js b/src/security/ClamAVEngineBase.js index 8b4818b..e2cda95 100644 --- a/src/security/ClamAVEngineBase.js +++ b/src/security/ClamAVEngineBase.js @@ -3,7 +3,19 @@ const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); +/** + * Base implementation for the ClamAV antivirus engine. + * + * Discovers `clamscan`/`freshclam` binaries, manages definition updates, + * and runs file/directory scans. Platform-specific subclasses override + * binary names and spawn options. + */ class ClamAVEngineBase { + /** + * @param {Object} [options] + * @param {string} [options.baseDir] - ClamAV installation directory. + * @param {string} [options.dbDir] - Virus definition directory. + */ constructor(options = {}) { const candidates = [ options.baseDir, @@ -12,7 +24,9 @@ class ClamAVEngineBase { path.join(__dirname, '..', '..', 'assets', 'clamav') ].filter(Boolean); - this.baseDir = candidates.find(dir => fs.existsSync(this._clamscanPath(dir))) || candidates[candidates.length - 1]; + const systemCandidates = this._systemPathCandidates(); + const allCandidates = [...candidates, ...systemCandidates]; + this.baseDir = allCandidates.find(dir => fs.existsSync(this._clamscanPath(dir))) || allCandidates[allCandidates.length - 1]; this.clamscanPath = this._clamscanPath(this.baseDir); this.freshclamPath = this._freshclamPath(this.baseDir); this.certsDir = path.join(this.baseDir, 'certs'); @@ -25,8 +39,54 @@ class ClamAVEngineBase { this.cancelUpdateRequested = false; } + /** + * Standard ClamAV install locations for the current platform. + * @returns {Array} + */ + _systemPathCandidates() { + const platform = process.platform; + if (platform === 'win32') { + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + return [ + path.join(programFiles, 'ClamAV'), + path.join(programFilesX86, 'ClamAV'), + path.join(process.env.ProgramData || 'C:\\ProgramData', 'ClamAV') + ]; + } + if (platform === 'darwin') { + return [ + '/usr/local/bin', + '/opt/homebrew/bin', + '/usr/local/clamav' + ]; + } + return [ + '/usr/bin', + '/usr/local/bin', + '/usr/local/clamav' + ]; + } + + /** + * Resolve the clamscan binary path for a given base directory. + * @param {string} baseDir + * @returns {string} + */ _clamscanPath(baseDir) { return path.join(baseDir, 'clamscan'); } + /** + * Resolve the freshclam binary path for a given base directory. + * @param {string} baseDir + * @returns {string} + */ _freshclamPath(baseDir) { return path.join(baseDir, 'freshclam'); } + /** + * Build spawn args for clamscan. + * @param {string} command + * @param {string} filePath + * @param {boolean} isDir + * @returns {Array} + */ _spawnArgs(command, filePath, isDir) { const args = [ '--stdout', @@ -37,10 +97,18 @@ class ClamAVEngineBase { args.push(filePath); return args; } + /** + * Spawn options for child processes. + * @returns {Object} + */ _spawnOptions() { return { windowsHide: true }; } + /** + * Initialize the engine: verify binaries, download definitions if needed. + * @returns {Promise} + */ async init() { if (!fs.existsSync(this.clamscanPath)) { logger.warn('ClamAV executable not found at ' + this.clamscanPath); @@ -63,6 +131,10 @@ class ClamAVEngineBase { logger.info('ClamAV engine initialized at ' + this.baseDir); } + /** + * Get current engine status. + * @returns {Object} + */ getStatus() { return { ready: this.isReady, @@ -73,6 +145,10 @@ class ClamAVEngineBase { }; } + /** + * Check whether virus definition databases are present. + * @returns {boolean} + */ hasVirusDatabase() { const dbFiles = [ 'main.cvd', 'daily.cvd', 'bytecode.cvd', @@ -87,6 +163,11 @@ class ClamAVEngineBase { } } + /** + * Update ClamAV virus definitions via freshclam. + * @param {Function} [onProgress] + * @returns {Promise<{success:boolean, canceled?:boolean, error?:string, output?:string}>} + */ updateDefinitions(onProgress) { if (!fs.existsSync(this.freshclamPath)) { return Promise.resolve({ success: false, error: this.freshclamPath + ' not found', output: '' }); @@ -163,6 +244,10 @@ class ClamAVEngineBase { }); } + /** + * Ensure a freshclam.conf exists in the database directory. + * @returns {string} Config file path. + */ ensureFreshclamConfig() { const configPath = path.join(this.dbDir, 'freshclam.conf'); const lines = [ @@ -183,10 +268,21 @@ class ClamAVEngineBase { return configPath; } + /** + * Convert a Windows path to a ClamAV-style forward-slash path. + * @param {string} value + * @returns {string} + */ toClamPath(value) { return path.resolve(value).replace(/\\/g, '/'); } + /** + * Scan a file or directory with clamscan. + * @param {string} filePath + * @param {Function} [onProgress] + * @returns {Promise} + */ async scanFile(filePath, onProgress) { if (!this.isReady) { return { success: false, error: 'ClamAV not ready', threatsFound: 0, filesScanned: 0, output: '' }; @@ -306,6 +402,10 @@ class ClamAVEngineBase { }); } + /** + * Request cancellation of the active scan and/or definition update. + * @returns {boolean} Whether a process was signaled to stop. + */ abortCurrentScan() { let killed = false; diff --git a/src/security/EmergencyLockdown.js b/src/security/EmergencyLockdown.js index da1dfdb..2aea932 100644 --- a/src/security/EmergencyLockdown.js +++ b/src/security/EmergencyLockdown.js @@ -28,6 +28,9 @@ class EmergencyLockdown { this._loadAllowlist(); } + /** + * Load the lockdown allowlist from the database. + */ _loadAllowlist() { try { const stored = this.db.get('lockdown_allowlist'); @@ -39,6 +42,9 @@ class EmergencyLockdown { } } + /** + * Persist the current allowlist to the database. + */ _saveAllowlist() { try { this.db.set('lockdown_allowlist', this.allowlist); @@ -47,10 +53,22 @@ class EmergencyLockdown { } } + /** + * Return a copy of the current allowlist. + * @returns {Object} + */ getAllowlist() { return { ...this.allowlist }; } + /** + * Replace the entire allowlist and persist it. + * @param {Object} allowlist + * @param {Array} [allowlist.interfaces] + * @param {Array} [allowlist.services] + * @param {Array} [allowlist.ips] + * @returns {Object} + */ setAllowlist(allowlist) { this.allowlist = { interfaces: allowlist.interfaces || [], @@ -61,6 +79,12 @@ class EmergencyLockdown { return this.allowlist; } + /** + * Add an entry to the allowlist. + * @param {'interfaces'|'services'|'ips'} type + * @param {string} value + * @returns {Object} + */ addToAllowlist(type, value) { if (!this.allowlist[type]) { this.allowlist[type] = []; @@ -73,6 +97,12 @@ class EmergencyLockdown { return this.allowlist; } + /** + * Remove an entry from the allowlist. + * @param {'interfaces'|'services'|'ips'} type + * @param {string} value + * @returns {Object} + */ removeFromAllowlist(type, value) { if (!this.allowlist[type]) return this.allowlist; const normalized = type === 'ips' ? value.trim() : value.trim().toLowerCase(); @@ -82,7 +112,8 @@ class EmergencyLockdown { } /** - * Get list of network interfaces + * Get list of network interfaces. + * @returns {Promise>} */ async getNetworkInterfaces() { try { @@ -111,7 +142,9 @@ class EmergencyLockdown { } /** - * Disable a network interface + * Disable a network interface by name. + * @param {string} interfaceName + * @returns {Promise<{success:boolean, interface:string}>} */ async disableInterface(interfaceName) { if (!interfaceName || !SAFE_INTERFACE_NAME.test(interfaceName)) { @@ -126,7 +159,9 @@ class EmergencyLockdown { } /** - * Enable a network interface + * Enable a network interface by name. + * @param {string} interfaceName + * @returns {Promise<{success:boolean, interface:string}>} */ async enableInterface(interfaceName) { if (!interfaceName || !SAFE_INTERFACE_NAME.test(interfaceName)) { @@ -141,7 +176,8 @@ class EmergencyLockdown { } /** - * Get list of non-essential Windows services + * Get list of non-essential Windows services. + * @returns {Promise>} */ async getNonEssentialServices() { const nonEssentialPatterns = [ @@ -193,7 +229,9 @@ class EmergencyLockdown { } /** - * Stop a Windows service + * Stop a Windows service. + * @param {string} serviceName + * @returns {Promise<{success:boolean, service:string}>} */ async stopService(serviceName) { try { @@ -205,7 +243,9 @@ class EmergencyLockdown { } /** - * Start a Windows service + * Start a Windows service. + * @param {string} serviceName + * @returns {Promise<{success:boolean, service:string}>} */ async startService(serviceName) { try { @@ -217,7 +257,8 @@ class EmergencyLockdown { } /** - * Emergency lockdown - disable all network interfaces and stop non-essential services + * Activate emergency lockdown: disable interfaces, stop non-essential services. + * @returns {Promise<{success:boolean, message?:string}>} */ async lockdown() { if (this.isLockedDown) { @@ -305,7 +346,8 @@ class EmergencyLockdown { } /** - * Restore from lockdown - re-enable network interfaces and restart services + * Restore from lockdown - re-enable network interfaces and restart services. + * @returns {Promise<{success:boolean, message?:string, results?:Object}>} */ async restore() { if (!this.isLockedDown) { @@ -395,7 +437,8 @@ class EmergencyLockdown { } /** - * Get current lockdown status + * Get current lockdown status. + * @returns {Object} */ getStatus() { return { diff --git a/src/security/FirewallManager.js b/src/security/FirewallManager.js index d290066..4373894 100644 --- a/src/security/FirewallManager.js +++ b/src/security/FirewallManager.js @@ -38,10 +38,24 @@ function friendlyFirewallError(e, fallback) { return new Error(fallback || 'Something went wrong updating the firewall. Please try again.'); } +/** + * Manages Windows Firewall rules for Soterios. + * + * All mutating operations are restricted to rules carrying the + * {@link APP_RULE_PREFIX} so the app never touches built-in Windows rules. + */ class FirewallManager { + /** + * @param {object} db - DatabaseService instance used for audit logging. + */ constructor(db) { this._db = db; } + /** + * Execute a PowerShell command and return stdout. + * @param {string} command + * @returns {Promise} + */ async runPowerShell(command) { const { stdout } = await execFilePromise('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000, @@ -50,6 +64,10 @@ class FirewallManager { return stdout; } + /** + * Get firewall profile statuses (Domain/Private/Public). + * @returns {Promise>} + */ async getStatus() { try { const stdout = await this.runPowerShell('Get-NetFirewallProfile | Select-Object Name, Enabled | ConvertTo-Json'); @@ -60,6 +78,10 @@ class FirewallManager { } } + /** + * Get aggregated firewall rule counts. + * @returns {Promise} Rule statistics. + */ async getRules() { try { const command = [ @@ -111,6 +133,10 @@ class FirewallManager { } } + /** + * List all Windows Firewall rules with app-managed metadata. + * @returns {Promise>} + */ async listRules() { try { const command = [ @@ -154,8 +180,20 @@ class FirewallManager { } } - // Generic rule creator. Only include the params you have — anything - // omitted is left unrestricted by Windows Firewall's defaults. + /** + * Create a new firewall rule. The rule name is automatically prefixed with + * {@link APP_RULE_PREFIX} unless it already carries it. + * @param {Object} spec + * @param {string} spec.name + * @param {string} spec.direction - "Inbound" or "Outbound" + * @param {string} spec.action - "Allow" or "Block" + * @param {string} [spec.protocol] + * @param {string} [spec.remoteAddress] + * @param {number|string} [spec.remotePort] + * @param {number|string} [spec.localPort] + * @param {string} [spec.program] + * @returns {Promise<{success:boolean, name?:string}>} + */ async createRule(spec) { const { name, direction, action, protocol, remoteAddress, remotePort, localPort, program } = spec || {}; if (!name || !direction || !action) throw new InvalidInputError('name, direction, and action are required.'); @@ -197,6 +235,11 @@ class FirewallManager { return { success: true, name: fullName }; } + /** + * Delete an app-managed firewall rule by display name. + * @param {string} name - Rule display name (must start with APP_RULE_PREFIX). + * @returns {Promise<{success:boolean}>} + */ async deleteRule(name) { if (!name || !name.startsWith(APP_RULE_PREFIX)) { throw new InvalidInputError('Only rules created in this app can be deleted here.'); @@ -210,6 +253,12 @@ class FirewallManager { return { success: true }; } + /** + * Enable or disable an app-managed firewall rule. + * @param {string} name - Rule display name (must start with APP_RULE_PREFIX). + * @param {boolean} enabled + * @returns {Promise<{success:boolean}>} + */ async setRuleEnabled(name, enabled) { if (!name || !name.startsWith(APP_RULE_PREFIX)) { throw new InvalidInputError('Only rules created in this app can be toggled here.'); @@ -223,10 +272,12 @@ class FirewallManager { return { success: true }; } - // Turns Windows Firewall on/off for a given profile (Domain/Private/Public). - // The IPC layer already validates `profile` against the same whitelist - // before this is ever called, but we check again here since this class - // shells out to PowerShell and should never trust its inputs blindly. + /** + * Turn a firewall profile (Domain/Private/Public) on or off. + * @param {string} profile - "Domain", "Private", or "Public". + * @param {boolean} enabled + * @returns {Promise<{success:boolean}>} + */ async setProfileEnabled(profile, enabled) { const VALID_PROFILES = ['Domain', 'Private', 'Public']; if (!VALID_PROFILES.includes(profile)) { @@ -240,7 +291,10 @@ class FirewallManager { return { success: true }; } - // Snapshot of Soterios-managed rules for backup / migrate across machines. + /** + * Export all Soterios-managed firewall rules for backup/migration. + * @returns {Promise<{version:number, exportedAt:string, prefix:string, rules:Array}>} + */ async exportRules() { const rules = await this.listRules(); const managed = rules.filter((r) => r.managedByApp); @@ -263,6 +317,11 @@ class FirewallManager { }; } + /** + * Normalize a port value for import. Accepts a single numeric port or "Any". + * @param {string|number|null|undefined} value + * @returns {number|undefined} + */ _normalizePort(value) { if (value == null || value === '') return undefined; const raw = String(value).trim(); @@ -278,6 +337,11 @@ class FirewallManager { return n; } + /** + * Normalize a protocol value for import. + * @param {string|null|undefined} value + * @returns {string|undefined} + */ _normalizeProtocol(value) { if (value == null || value === '') return undefined; const protocol = String(value).trim(); @@ -288,6 +352,11 @@ class FirewallManager { return protocol === 'Any' ? undefined : protocol; } + /** + * Normalize a remote address for import. + * @param {string|null|undefined} value + * @returns {string|undefined} + */ _normalizeRemoteAddress(value) { if (value == null || value === '') return undefined; const raw = String(value).trim(); @@ -300,6 +369,11 @@ class FirewallManager { return raw; } + /** + * Validate a single imported rule shape before creating it. + * @param {Object} rule + * @param {number} index + */ _validateImportRule(rule, index) { if (!rule || typeof rule !== 'object' || Array.isArray(rule)) { throw new InvalidInputError(`Rule at index ${index} is invalid.`); @@ -329,6 +403,14 @@ class FirewallManager { } } + /** + * Import firewall rules from a previously exported payload. + * @param {Object} payload + * @param {number} [payload.version] + * @param {Array} [payload.rules] + * @param {string} [options.onConflict] - "skip" | "overwrite" | "rename" + * @returns {Promise} Import summary. + */ async importRules(payload, options = {}) { const onConflict = ['skip', 'overwrite', 'rename'].includes(options.onConflict) ? options.onConflict diff --git a/src/security/FolderWatcher.js b/src/security/FolderWatcher.js index 9ace2c0..45424a7 100644 --- a/src/security/FolderWatcher.js +++ b/src/security/FolderWatcher.js @@ -48,6 +48,10 @@ class FolderWatcher { ]; } + /** + * Get current watcher status. + * @returns {Object} + */ getStatus() { return { running: this._running, @@ -56,6 +60,10 @@ class FolderWatcher { }; } + /** + * Start watching configured directories. + * @returns {Object} + */ start() { if (this._running) return this.getStatus(); this._running = true; @@ -65,6 +73,10 @@ class FolderWatcher { return this.getStatus(); } + /** + * Stop all watchers and clear the queue. + * @returns {Object} + */ stop() { this._running = false; for (const [, watcher] of this._watchers) { @@ -79,6 +91,10 @@ class FolderWatcher { return this.getStatus(); } + /** + * Watch a single directory for file changes. + * @param {string} dir + */ _watchDir(dir) { try { if (!fs.existsSync(dir)) return; @@ -100,6 +116,10 @@ class FolderWatcher { } } + /** + * Debounce a file path before enqueuing it. + * @param {string} filePath + */ _schedule(filePath) { const existing = this._pending.get(filePath); if (existing) clearTimeout(existing); @@ -111,6 +131,10 @@ class FolderWatcher { this._pending.set(filePath, timer); } + /** + * Enqueue a file for scanning if it passes cooldown and dedup checks. + * @param {string} filePath + */ _enqueue(filePath) { try { const st = fs.statSync(filePath); @@ -125,6 +149,9 @@ class FolderWatcher { this._drain(); } + /** + * Drain the scan queue, processing one file at a time. + */ async _drain() { if (this._draining) return; this._draining = true; diff --git a/src/security/HeuristicEngine.js b/src/security/HeuristicEngine.js index 51611d5..29c0992 100644 --- a/src/security/HeuristicEngine.js +++ b/src/security/HeuristicEngine.js @@ -9,6 +9,11 @@ const EXECUTABLE_EXTENSIONS = new Set([ '.exe', '.dll', '.scr', '.com', '.bat', '.cmd', '.msi', '.ps1', '.vbs', '.js', '.jar', '.sys' ]); +/** + * Calculate Shannon entropy for a buffer. + * @param {Buffer} buffer + * @returns {number} + */ function shannonEntropy(buffer) { if (!buffer.length) return 0; const freq = new Uint32Array(256); @@ -23,9 +28,20 @@ function shannonEntropy(buffer) { return entropy; } +/** + * lightweight file heuristic analysis. + * + * Produces a suspicion score and signal list based on file size, + * entropy, path, and extension heuristics. + */ class HeuristicEngine { constructor() {} + /** + * Analyze a file and return a heuristic suspicion score. + * @param {string} filePath + * @returns {Promise<{score:number, signals:Array}>} + */ async analyze(filePath) { const empty = { score: 0, signals: [] }; if (!filePath || typeof filePath !== 'string') return empty; diff --git a/src/security/NetworkAlertMonitor.js b/src/security/NetworkAlertMonitor.js index 575644d..af11f4f 100644 --- a/src/security/NetworkAlertMonitor.js +++ b/src/security/NetworkAlertMonitor.js @@ -30,6 +30,10 @@ class NetworkAlertMonitor { this._lastHits = []; } + /** + * Get current monitor status. + * @returns {Object} + */ getStatus() { return { running: this._running, @@ -38,6 +42,10 @@ class NetworkAlertMonitor { }; } + /** + * Start polling for suspicious connections. + * @returns {Object} + */ start() { if (this._running) return this.getStatus(); this._running = true; @@ -49,6 +57,10 @@ class NetworkAlertMonitor { return this.getStatus(); } + /** + * Stop polling. + * @returns {Object} + */ stop() { this._running = false; if (this._timer) clearInterval(this._timer); @@ -56,11 +68,21 @@ class NetworkAlertMonitor { return this.getStatus(); } + /** + * Ignore a connection key or remote address. + * @param {string} key + * @returns {Object} + */ ignore(key) { if (key) this._ignored.add(String(key)); return { success: true }; } + /** + * Kill a process by PID via ProcessInspector. + * @param {number|string} pid + * @returns {Promise<{success:boolean, error?:string}>} + */ async kill(pid) { const n = Number(pid); if (!Number.isInteger(n) || n <= 0) return { success: false, error: 'Invalid PID' }; @@ -70,10 +92,19 @@ class NetworkAlertMonitor { return this.processInspector.killProcess(n); } + /** + * Build a dedup key for a connection. + * @param {Object} conn + * @returns {string} + */ _key(conn) { return `${conn.OwningProcess || 0}|${conn.RemoteAddress || ''}|${conn.RemotePort || ''}`; } + /** + * Poll current connections and alert on blocklisted IPs. + * @returns {Promise} + */ async poll() { if (!this.networkMonitor || !this.blocklistService) return []; let connections = []; diff --git a/src/security/NetworkMonitor.js b/src/security/NetworkMonitor.js index 7297b03..da006a3 100644 --- a/src/security/NetworkMonitor.js +++ b/src/security/NetworkMonitor.js @@ -20,7 +20,17 @@ async function runPs1(scriptName) { return stdout; } +/** + * Reads Windows network connections and interface statistics. + * + * Connection data comes from a PowerShell helper script; interface stats + * come from `systeminformation`. + */ class NetworkMonitor { + /** + * Get active network connections. + * @returns {Promise>} + */ async getConnections() { try { const stdout = await runPs1('network-connections.ps1'); @@ -33,6 +43,10 @@ class NetworkMonitor { } } + /** + * Get network interface stats and connection summary. + * @returns {Promise<{interfaces:Array, connections:Object}>} + */ async getStats() { try { const netStats = await si.networkStats(); diff --git a/src/security/ProcessInspector.js b/src/security/ProcessInspector.js index 324fe98..d6b20fe 100644 --- a/src/security/ProcessInspector.js +++ b/src/security/ProcessInspector.js @@ -48,18 +48,29 @@ function isSystemDirectoryPath(filePath) { return SYSTEM_DIR_MARKERS.some((marker) => lower.includes(marker)); } -// ps-list is ESM-only so we must use dynamic import() +/** + * Inspects running processes, assesses suspicious characteristics, and + * can terminate non-critical processes on request. + * + * Critical system PIDs/names and the Soterios process itself are protected. + */ class ProcessInspector { + /** + * @param {Object} [options] + * @param {object} [options.db] - DatabaseService for audit logging. + * @param {Function} [options.getSignatureInfo] - Signature lookup override. + */ constructor(options = {}) { this._db = options.db || null; this._getSignatureInfo = options.getSignatureInfo || getSignatureInfo; } - // ps-list's Windows output doesn't include a separate executable path - // field — only the full command line. This pulls the executable portion - // out of it on a best-effort basis (handles the common quoted-path case; - // unquoted paths containing spaces can't be split reliably, so this is an - // approximation, not a guarantee). + /** + * Extract the executable path from a command line string. + * Handles quoted paths; unquoted paths with spaces are approximate. + * @param {string} cmd + * @returns {string|null} + */ _extractPathFromCmd(cmd) { if (!cmd) return null; const trimmed = cmd.trim(); @@ -72,6 +83,15 @@ class ProcessInspector { return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); } + /** + * Assess whether a process looks suspicious based on path, name, and signature. + * @param {Object} proc + * @param {number} proc.pid + * @param {string} proc.name + * @param {string} proc.cmd + * @param {string} [proc.path] + * @returns {Promise} + */ async _assessSuspicious(proc) { const reasons = []; const locationReasons = []; @@ -108,6 +128,10 @@ class ProcessInspector { }; } + /** + * Get all running processes with suspicion assessment. + * @returns {Promise>} + */ async getProcesses() { try { const { default: psList } = await import('ps-list'); @@ -131,6 +155,11 @@ class ProcessInspector { } } + /** + * Terminate a process after safety checks. + * @param {number} pid + * @returns {Promise<{success:boolean, error?:string}>} + */ async killProcess(pid) { const numericPid = Number(pid); diff --git a/src/security/QuarantineManager.js b/src/security/QuarantineManager.js index a97ab39..5765677 100644 --- a/src/security/QuarantineManager.js +++ b/src/security/QuarantineManager.js @@ -1,3 +1,11 @@ +/** + * Manages quarantined threat files using AES-256-GCM encryption. + * + * Each quarantined file is encrypted with a machine-specific key derived + * from a random key stored on disk (with restrictive permissions). Legacy + * files encrypted with the old hostname-derived key can still be decrypted + * via a fallback path. + */ const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -5,17 +13,26 @@ const crypto = require('crypto'); const logger = require('../utils/logger'); const { InvalidInputError } = require('../utils/errors'); const { log, ACTIONS } = require('../core/auditLog'); +const { QuarantineKeyStore } = require('../utils/quarantineKeyStore'); const PBKDF2_ITERATIONS = 100_000; const PBKDF2_HASH = 'sha256'; const PBKDF2_KEY_LENGTH = 32; // 256-bit AES key const PBKDF2_SALT = Buffer.from('Soterios-Quarantine-KDF-v1', 'utf8'); +const ENCRYPTED_FILE_VERSION = 1; // first 4 bytes: little-endian version marker +/** + * @param {object} db - DatabaseService with quarantine record helpers. + * @param {object} [options] + * @param {string} [options.quarantineDir] - Override quarantine directory (tests). + * @param {Buffer} [options.key] - Direct encryption key override (tests). + */ class QuarantineManager { /** * @param {object} db - DatabaseService with quarantine record helpers. * @param {object} [options] * @param {string} [options.quarantineDir] - Override quarantine directory (tests). + * @param {Buffer} [options.key] - Direct encryption key override (tests). */ constructor(db, options = {}) { this.db = db; @@ -24,31 +41,82 @@ class QuarantineManager { fs.mkdirSync(this.quarantineDir, { recursive: true }); } - // Derive a machine-specific encryption key. This is not a password — it's - // a convenience secret so quarantined files from one machine cannot be - // trivially decrypted on another. A determined local attacker can still - // recover the key from memory, but casual inspection of the quarantined - // file is no longer sufficient. + const keyStore = new QuarantineKeyStore({ ...options, storageDir: options.quarantineDir }); + this._key = keyStore.key; + + // Legacy fallback key derived from machine-specific info. Used only when + // decrypting older quarantined files that were encrypted before the random + // key store was introduced. const machineSecret = `${os.hostname()}\x00${os.userInfo().username}`; - this._key = crypto.pbkdf2Sync(machineSecret, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, PBKDF2_HASH); + this._legacyKey = crypto.pbkdf2Sync(machineSecret, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, PBKDF2_HASH); } + /** + * Encrypt a buffer with AES-256-GCM and prepend a version marker. + * @param {Buffer} data - Plaintext. + * @returns {Buffer} Encrypted payload. + */ _encrypt(data) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', this._key, iv); const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, encrypted]); + // Version marker + iv + tag + encrypted data + const versionBuf = Buffer.alloc(4); + versionBuf.writeUInt32LE(ENCRYPTED_FILE_VERSION, 0); + return Buffer.concat([versionBuf, iv, tag, encrypted]); } + /** + * Decrypt a quarantined file payload. Tries the current random key first, + * then falls back to the legacy hostname-derived key for old files. + * @param {Buffer} buffer - Encrypted payload. + * @returns {Buffer} Plaintext. + */ _decrypt(buffer) { if (buffer.length < 28) { throw new InvalidInputError('Quarantined file is too short to be valid.'); } - const iv = buffer.slice(0, 12); - const tag = buffer.slice(12, 28); - const encrypted = buffer.slice(28); - const decipher = crypto.createDecipheriv('aes-256-gcm', this._key, iv); + + // Try new format with version marker first. + const versionMarker = buffer.readUInt32LE(0); + if (versionMarker === ENCRYPTED_FILE_VERSION) { + if (buffer.length < 32) { + throw new InvalidInputError('Quarantined file is too short to be valid.'); + } + const iv = buffer.slice(4, 16); + const tag = buffer.slice(16, 32); + const encrypted = buffer.slice(32); + return this._decryptWithKey(this._key, iv, tag, encrypted); + } + + // Legacy format: iv(12) + tag(16) + encrypted (no version marker). + if (buffer.length >= 28) { + const iv = buffer.slice(0, 12); + const tag = buffer.slice(12, 28); + const encrypted = buffer.slice(28); + try { + return this._decryptWithKey(this._key, iv, tag, encrypted); + } catch (_) { + // Fall back to legacy hostname-derived key for files quarantined + // before the random key store was introduced. + return this._decryptWithKey(this._legacyKey, iv, tag, encrypted); + } + } + + throw new InvalidInputError('Quarantined file has an unsupported format.'); + } + + /** + * Decrypt with an explicit key. + * @param {Buffer} key + * @param {Buffer} iv + * @param {Buffer} tag + * @param {Buffer} encrypted + * @returns {Buffer} + */ + _decryptWithKey(key, iv, tag, encrypted) { + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(encrypted), decipher.final()]); } diff --git a/src/security/RealTimeWatcher.js b/src/security/RealTimeWatcher.js index a61baba..ab9c080 100644 --- a/src/security/RealTimeWatcher.js +++ b/src/security/RealTimeWatcher.js @@ -1,7 +1,16 @@ const logger = require('../utils/logger'); const SystemAudit = require('./SystemAudit'); +/** + * Monitors Windows Defender real-time protection state and verifies + * that it remains enabled after tamper-protection bypass attempts. + */ class RealTimeWatcher { + /** + * @param {object} db + * @param {object} eventBus + * @param {object} scanEngine + */ constructor(db, eventBus, scanEngine) { this.db = db; this.eventBus = eventBus; @@ -9,6 +18,10 @@ class RealTimeWatcher { this.audit = new SystemAudit(); } + /** + * Check whether Windows Defender is available on this system. + * @returns {Promise} + */ async isDefenderAvailable() { try { const result = await this.audit.runPowerShell('Get-MpComputerStatus | Select-Object -ExpandProperty RealTimeProtectionEnabled'); @@ -19,6 +32,11 @@ class RealTimeWatcher { } } + /** + * Verify that Defender real-time protection matches the expected state. + * @param {boolean} expected + * @returns {Promise<{ok:boolean, enabled:boolean|null, error?:string}>} + */ async verifyRealtimeState(expected) { const isAvailable = await this.isDefenderAvailable(); if (!isAvailable) { @@ -53,6 +71,10 @@ class RealTimeWatcher { return { ok: true, enabled, error: null }; } + /** + * Enable Windows Defender real-time protection. + * @returns {Promise<{ok:boolean, enabled:boolean|null, error?:string}>} + */ async start() { const isAvailable = await this.isDefenderAvailable(); if (!isAvailable) { @@ -75,6 +97,10 @@ class RealTimeWatcher { return this.verifyRealtimeState(true); } + /** + * Disable Windows Defender real-time protection. + * @returns {Promise<{ok:boolean, enabled:boolean|null, error?:string}>} + */ async stop() { const isAvailable = await this.isDefenderAvailable(); if (!isAvailable) { @@ -97,6 +123,10 @@ class RealTimeWatcher { return this.verifyRealtimeState(false); } + /** + * Get the current real-time protection status. + * @returns {Promise<{ok:boolean, enabled:boolean|null, error?:string}>} + */ async getStatus() { const isAvailable = await this.isDefenderAvailable(); if (!isAvailable) { diff --git a/src/security/ReputationEngine.js b/src/security/ReputationEngine.js index 504feae..55a6a60 100644 --- a/src/security/ReputationEngine.js +++ b/src/security/ReputationEngine.js @@ -1,23 +1,50 @@ const SHA256_PATTERN = /^[a-f0-9]{64}$/i; const VALID_VERDICTS = new Set(['safe', 'malicious']); +/** + * Local reputation store for file hashes. + * + * Stores safe/malicious verdicts in the database for fast lookup + * without relying on external APIs. + */ class ReputationEngine { + /** + * @param {object} db - DatabaseService with reputation helpers. + */ constructor(db) { this.db = db; } + /** + * Normalize a SHA-256 hash string. + * @param {string} hash + * @returns {string|null} + */ static normalizeHash(hash) { if (typeof hash !== 'string') return null; const normalized = hash.trim().toLowerCase(); return SHA256_PATTERN.test(normalized) ? normalized : null; } + /** + * Look up a hash verdict from the local store. + * @param {string} hash + * @returns {Promise} + */ async checkHash(hash) { const normalized = ReputationEngine.normalizeHash(hash); if (!normalized) return null; return this.db.getReputationHash(normalized); } + /** + * Add or update a hash verdict in the local store. + * @param {string} hash + * @param {'safe'|'malicious'} verdict + * @param {string} [note] + * @param {string} [source] + * @returns {Promise<{success:boolean, hash?:string, verdict?:string, error?:string}>} + */ async addHash(hash, verdict, note = null, source = 'user') { const normalized = ReputationEngine.normalizeHash(hash); if (!normalized) { @@ -35,6 +62,11 @@ class ReputationEngine { return { success: true, hash: normalized, verdict }; } + /** + * Remove a hash verdict from the local store. + * @param {string} hash + * @returns {Promise<{success:boolean, error?:string}>} + */ async removeHash(hash) { const normalized = ReputationEngine.normalizeHash(hash); if (!normalized) { @@ -44,6 +76,11 @@ class ReputationEngine { return removed ? { success: true } : { success: false, error: 'Hash not found.' }; } + /** + * List stored hash verdicts. + * @param {number} [limit] + * @returns {Promise} + */ async listHashes(limit = 500) { return this.db.listReputationHashes(limit); } diff --git a/src/security/ScanEngine.js b/src/security/ScanEngine.js index c25357b..c90260e 100644 --- a/src/security/ScanEngine.js +++ b/src/security/ScanEngine.js @@ -6,10 +6,20 @@ const { clampProgress } = require('../core/scanProgress'); const { scanReportsDir } = require('./reportExport'); const { renderTemplate } = require('../utils/templates'); +/** + * Escape a string for safe insertion into HTML. + * @param {*} v - Value to escape. + * @returns {string} Escaped string. + */ function esc(v) { return String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } +/** + * Render a scan report as an HTML string. + * @param {Object} report - Scan report payload. + * @returns {string} HTML document string. + */ function renderScanReportHtml(report) { const threatRows = report.threats.length ? report.threats.map((t) => `${esc(t.name)}${esc(t.path)}`).join('') @@ -33,7 +43,72 @@ function renderScanReportHtml(report) { }); } +/** + * Walk paths and collect file metadata for incremental scan comparison. + * @param {string[]} paths - Root paths to walk. + * @returns {Array<{ path: string, size: number|null, modifiedAt: string|null }>} File metadata objects. + */ +function collectFileMetadatas(paths) { + const metadatas = []; + const visited = new Set(); + + function walk(current) { + if (visited.has(current)) return; + visited.add(current); + let stat; + try { + stat = fs.statSync(current); + } catch (_) { + return; + } + if (stat.isFile()) { + metadatas.push({ + path: current, + size: stat.size, + modifiedAt: new Date(stat.mtime).toISOString() + }); + return; + } + if (stat.isDirectory()) { + try { + const entries = fs.readdirSync(current); + for (const entry of entries) { + const full = path.join(current, entry); + // Skip junctions/symlinks to avoid cycles and permission issues. + let entryStat; + try { + entryStat = fs.lstatSync(full); + } catch (_) { + continue; + } + if (entryStat.isSymbolicLink()) continue; + walk(full); + } + } catch (_) { + // Permission denied or other access error — skip this directory. + } + } + } + + for (const target of paths || []) { + walk(target); + } + return metadatas; +} + +/** + * Coordinates ClamAV scans, progress reporting, threat quarantine, + * and scan report generation. + */ class ScanEngine { + /** + * @param {DatabaseService} db + * @param {EventBus} eventBus + * @param {ClamAVEngine} clamEngine + * @param {HeuristicEngine} heuristicEngine + * @param {ReputationEngine} reputationEngine + * @param {QuarantineManager} quarantineManager + */ constructor(db, eventBus, clamEngine, heuristicEngine, reputationEngine, quarantineManager) { this.db = db; this.eventBus = eventBus; @@ -41,7 +116,7 @@ class ScanEngine { this.heuristicEngine = heuristicEngine; this.reputationEngine = reputationEngine; this.quarantineManager = quarantineManager; - + // Separate state for user scans and folder-watch scans this.userScan = { abortController: null, @@ -73,6 +148,10 @@ class ScanEngine { return this.folderWatchScan.isScanning; } + /** + * Run a quick scan against common system temp/startup locations. + * @returns {Promise} Scan result. + */ async runQuickScan() { if (this.userScan.isScanning) return { error: 'Scan already in progress' }; @@ -96,17 +175,35 @@ class ScanEngine { return this.runScan('quick', targets, 'Quick scan starting...'); } + /** + * Run a full system scan starting from C:\. + * @returns {Promise} Scan result. + */ async runFullScan() { if (this.userScan.isScanning) return { error: 'Scan already in progress' }; return this.runScan('full', ['C:\\'], 'Full scan starting (this may take a while)...'); } + /** + * Run a custom scan against user-specified paths. + * @param {string[]} paths - Target paths. + * @returns {Promise} Scan result. + */ async runCustomScan(paths) { if (this.userScan.isScanning) return { error: 'Scan already in progress' }; return this.runScan('custom', paths, 'Custom scan starting...'); } + /** + * Core scan orchestrator. Runs a single scan of the given type and paths, + * emitting progress events and quarantining any threats found. + * + * @param {'quick'|'full'|'custom'|'folderwatch'} scanType + * @param {string[]} paths - Target paths. + * @param {string} startMessage - Initial progress message. + * @returns {Promise} Scan result. + */ async runScan(scanType, paths, startMessage) { const isFolderWatch = scanType === 'folderwatch'; const scanState = isFolderWatch ? this.folderWatchScan : this.userScan; @@ -135,7 +232,12 @@ class ScanEngine { // Build skip set for incremental scans (full scans only). const isFullScan = scanType === 'full'; - const skipPaths = isFullScan ? this.db.getFilesToSkip(paths) : new Set(); + const scanMetadatas = isFullScan ? paths.map((p) => { + let stat; + try { stat = fs.statSync(p); } catch (_) { stat = null; } + return { path: p, size: stat ? stat.size : null, modifiedAt: stat ? new Date(stat.mtime).toISOString() : null }; + }) : []; + const skipPaths = isFullScan ? this.db.getFilesToSkip(scanMetadatas) : new Set(); // Progress must never move backward within a single scan. Previously, // each target path computed its own fresh, lower "basePct" and emitted @@ -166,6 +268,7 @@ class ScanEngine { } const targetPath = paths[i]; + const basePct = Math.round((i / paths.length) * 80 + 10); // Incremental scan: skip paths that haven't changed since last scan. if (skipPaths.has(targetPath)) { @@ -173,7 +276,6 @@ class ScanEngine { continue; } - const basePct = Math.round((i / paths.length) * 80 + 10); emitProgress(basePct, 'Scanning ' + targetPath + '...'); let pathLastChecked = 0; @@ -212,12 +314,17 @@ class ScanEngine { // Record scanned path for incremental scans. try { - const stat = fs.statSync(targetPath); - this.db.recordScannedFile({ - path: targetPath, - size: stat.size, - modifiedAt: stat.mtime.toISOString() - }); + const meta = isFullScan ? scanMetadatas[i] : null; + if (meta) { + this.db.recordScannedFile(meta); + } else { + const stat = fs.statSync(targetPath); + this.db.recordScannedFile({ + path: targetPath, + size: stat.size, + modifiedAt: stat.mtime.toISOString() + }); + } } catch (_) { // Non-fatal: record best-effort for incremental cache. } @@ -328,6 +435,10 @@ class ScanEngine { }; } + /** + * Abort the current user-initiated scan. + * @returns {Object} Abort result. + */ abortScan() { // Only abort user scans, not folder-watch scans if (!this.userScan.isScanning) { @@ -341,6 +452,10 @@ class ScanEngine { return { success: true, canceled: true }; } + /** + * Get the current scan status. + * @returns {Object} Status object. + */ getStatus() { const activeScan = this.userScan.isScanning ? this.userScan : this.folderWatchScan.isScanning ? this.folderWatchScan @@ -354,6 +469,11 @@ class ScanEngine { }; } + /** + * Persist a scan report to disk and the database. + * @param {Object} report - Scan report payload. + * @returns {Object} Saved report with file paths. + */ saveScanReport(report) { const shouldSaveHistory = this.db.getSetting('feature.scanHistory', true); if (!shouldSaveHistory) { diff --git a/src/security/SystemAudit.js b/src/security/SystemAudit.js index 938884c..ae5dc81 100644 --- a/src/security/SystemAudit.js +++ b/src/security/SystemAudit.js @@ -6,19 +6,44 @@ const path = require('path'); const fs = require('fs'); const i18n = require('../i18n'); +/** + * Runs local Windows security audit checks. + * + * Checks are executed via PowerShell and include Defender, UAC, Windows + * Update, BitLocker, execution policy, and Secure Boot. + */ class SystemAudit { + /** + * @param {string} [locale] - Locale for localized messages. + */ constructor() { this.locale = 'en'; } + /** + * Set the locale used for audit result messages. + * @param {string} locale + */ setLocale(locale) { this.locale = locale || 'en'; } + /** + * Translate a message key using the current locale. + * @param {string} key + * @param {Object} [vars] + * @returns {string} + */ t(key, vars = {}) { return i18n.t(key, this.locale, vars); } + /** + * Run a PowerShell script snippet and return parsed stdout/stderr. + * @param {string} script + * @param {number} [timeoutMs] + * @returns {Promise<{ok:boolean, stdout?:string, stderr?:string, error?:string}>} + */ async runPowerShell(script, timeoutMs = 15000) { try { const { stdout, stderr } = await execPromise( @@ -37,6 +62,10 @@ class SystemAudit { } } + /** + * Check Windows Defender status. + * @returns {Promise>} + */ async checkDefender() { const def = await this.runPowerShell(`Get-MpComputerStatus | Select-Object AMServiceEnabled, AntivirusEnabled, RealTimeProtectionEnabled, AMEngineVersion, AntivirusSignatureVersion, AntivirusSignatureAge | ConvertTo-Json`); const out = []; @@ -58,6 +87,10 @@ class SystemAudit { return out; } + /** + * Check User Account Control (UAC) status. + * @returns {Promise>} + */ async checkUac() { const uac = await this.runPowerShell(`(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System').EnableLUA`); if (uac.ok) { @@ -72,6 +105,10 @@ class SystemAudit { return [{ name: 'User Account Control', status: 'error', message: 'Could not check UAC status.' }]; } + /** + * Check pending Windows Updates. + * @returns {Promise>} + */ async checkWindowsUpdate() { // Primary: COM query (comprehensive but may include driver/optional updates) const up = await this.runPowerShell(`$session = New-Object -ComObject Microsoft.Update.Session -ErrorAction Stop; $searcher = $session.CreateUpdateSearcher(); $pending = $searcher.Search('IsInstalled=0 and IsHidden=0 and Type=\'Software\''); $pending.Updates.Count`, 90000); @@ -103,6 +140,10 @@ class SystemAudit { return [{ name: 'Windows Updates', status: 'warn', message: 'Could not query update status.', detail: up.error || 'Windows Update may be disabled or the COM query timed out.', recommendation: 'Check Windows Update in Settings manually.' }]; } + /** + * Check BitLocker drive encryption status. + * @returns {Promise>} + */ async checkBitLocker() { const bl = await this.runPowerShell(`Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop | Select-Object ProtectionStatus | ConvertTo-Json`); if (bl.ok) { @@ -138,6 +179,10 @@ class SystemAudit { return [{ name: 'BitLocker', status: 'info', message: 'BitLocker is not available on this system.', detail: 'Requires Windows Pro/Enterprise and a TPM chip.' }]; } + /** + * Check PowerShell execution policy. + * @returns {Promise>} + */ async checkExecutionPolicy() { const ep = await this.runPowerShell(`try { (Get-ExecutionPolicy -Scope LocalMachine -ErrorAction Stop).ToString() } catch { '' }`); if (ep.ok) { @@ -154,6 +199,10 @@ class SystemAudit { return [{ name: 'PowerShell Execution Policy', status: 'warn', message: 'PowerShell execution policy query failed.', detail: ep.error || 'Unable to query execution policy.', recommendation: 'Check execution policy with Get-ExecutionPolicy -List in PowerShell.' }]; } + /** + * Check Secure Boot status. + * @returns {Promise>} + */ async checkSecureBoot() { const sb = await this.runPowerShell(`Confirm-SecureBootUEFI`); if (sb.ok) { @@ -168,6 +217,11 @@ class SystemAudit { return [{ name: 'Secure Boot', status: 'info', message: 'Secure Boot status could not be determined.', detail: 'This check may not be supported on virtual machines or older hardware.' }]; } + /** + * Run all audit checks concurrently and report progress. + * @param {Function} [onProgress] + * @returns {Promise} Flat array of check results in display order. + */ async runAudit(onProgress) { // All six checks are independent of each other, so run them concurrently // instead of sequentially. Each PowerShell spawn has significant cold-start diff --git a/src/tools/actionCenter.js b/src/tools/actionCenter.js index 72ff63b..8f11459 100644 --- a/src/tools/actionCenter.js +++ b/src/tools/actionCenter.js @@ -1,5 +1,21 @@ +/** + * Action Center tool. + * + * Generates prioritized security and maintenance recommendations + * based on scan history, quarantine state, and system metrics. + */ + const si = require('systeminformation'); +/** + * Build a recommendation object. + * @param {string} id + * @param {'danger'|'warn'|'ok'} level + * @param {string} title + * @param {string} detail + * @param {string} actionPage + * @returns {Object} + */ function recommendation(id, level, title, detail, actionPage) { return { id, level, title, detail, actionPage }; } diff --git a/src/tools/cleanupTool.js b/src/tools/cleanupTool.js index 42d34fb..c2d3f9a 100644 --- a/src/tools/cleanupTool.js +++ b/src/tools/cleanupTool.js @@ -1,5 +1,16 @@ +/** + * Cleanup and maintenance tool definitions. + * + * Provides script listing and execution capabilities. + */ + const { loadRegistry, runScript } = require('../scripts/scriptRunner'); +/** + * Summarize a script result into a compact object. + * @param {Object} result + * @returns {Object} + */ function summarizeScriptResult(result) { if (!result || typeof result !== 'object') return {}; if (Array.isArray(result.removed) || Array.isArray(result.skipped)) { diff --git a/src/tools/passwordTools.js b/src/tools/passwordTools.js index 18d4bc2..a7446cd 100644 --- a/src/tools/passwordTools.js +++ b/src/tools/passwordTools.js @@ -1,3 +1,10 @@ +/** + * Password strength and generation tools. + * + * Includes common-password checks, entropy estimation, and + * secure random password generation. + */ + const crypto = require('crypto'); const LOWER = 'abcdefghijklmnopqrstuvwxyz'; diff --git a/src/utils/quarantineKeyStore.js b/src/utils/quarantineKeyStore.js new file mode 100644 index 0000000..2fb62b4 --- /dev/null +++ b/src/utils/quarantineKeyStore.js @@ -0,0 +1,82 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +const KEY_FILE_NAME = 'quarantine.key'; +const KEY_LENGTH = 32; // 256-bit AES key + +/** + * Simple file-backed key store with OS-level file restrictions. + * + * On Windows the key file is additionally locked down with icacls so that + * only the current user can read it. On other platforms the file is created + * with 0o600 permissions. + * + * For tests or one-off overrides, pass `options.key` directly. + */ +class QuarantineKeyStore { + /** + * @param {object} [options] + * @param {Buffer} [options.key] - Direct key override (tests). + * @param {string} [options.storageDir] - Directory for key file. + */ + constructor(options = {}) { + if (options.key) { + this._key = options.key; + return; + } + + const storageDir = options.storageDir || path.join(os.homedir(), '.soterios-quarantine'); + this._keyPath = path.join(storageDir, KEY_FILE_NAME); + this._key = this._loadOrCreateKey(storageDir); + } + + get key() { + return this._key; + } + + _loadOrCreateKey(storageDir) { + try { + if (fs.existsSync(this._keyPath)) { + const stored = fs.readFileSync(this._keyPath); + if (stored.length === KEY_LENGTH) { + return stored; + } + } + } catch (_) { + // Fall through to key generation on any read error. + } + + const key = crypto.randomBytes(KEY_LENGTH); + try { + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync(this._keyPath, key); + this._restrictKeyFile(); + } catch (_) { + // If we cannot persist the key, keep it in memory only. Quarantine + // functionality will be limited to this process lifetime. + } + return key; + } + + _restrictKeyFile() { + try { + fs.chmodSync(this._keyPath, 0o600); + } catch (_) { + // Best-effort. + } + if (process.platform === 'win32') { + try { + const { execFileSync } = require('child_process'); + execFileSync('icacls', [this._keyPath, '/inheritance:r', '/grant:r', `%USERNAME%:R`], { stdio: 'ignore', timeout: 5000 }); + } catch (_) { + // Best-effort. + } + } + } +} + +module.exports = { QuarantineKeyStore };