// Arx (Devseis Endpoint Auditor) - Electron main process. // Runs the read-only collector for this OS (with the OS's own administrator prompt), streams progress // to the window, turns the report into a PDF and saves it where the user chooses or, after 60 seconds // without an answer, in Documents/Arx Reports/-/. const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); const { spawn } = require('node:child_process'); const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const vulndb = require('./vulndb.cjs'); // Renamed to Arx in 0.4.3: keep the old settings folder, so baseline.conf, organisational.conf, the vulnerability data // and the downloaded AI model stay where earlier versions put them. app.setPath('userData', path.join(app.getPath('appData'), 'Devseis Endpoint Auditor')); // WebGPU (used by WebLLM) is still behind a flag on Linux. if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-unsafe-webgpu'); app.commandLine.appendSwitch('enable-features', 'Vulkan'); } // Audit files: next to the app when packaged, the repository root during development. const AUDIT_ROOT = app.isPackaged ? path.join(process.resourcesPath, 'audit') : path.join(__dirname, '..'); const OS_FAMILY = { win32: 'windows', darwin: 'macos', linux: 'linux' }[process.platform]; const readJson = (...parts) => JSON.parse(fs.readFileSync(path.join(AUDIT_ROOT, ...parts), 'utf8')); // Settings the organisation may edit: copied once to the user's folder // (~/Library/Application Support/Devseis Endpoint Auditor on macOS) and read from there. function settingsFile(name) { const user = path.join(app.getPath('userData'), name); try { if (!fs.existsSync(user)) { fs.mkdirSync(path.dirname(user), { recursive: true }); fs.copyFileSync(path.join(AUDIT_ROOT, name), user); } return user; } catch { return path.join(AUDIT_ROOT, name); } } ipcMain.handle('settings:open', () => shell.openPath(path.dirname(settingsFile('baseline.conf')))); // Model version: published on the Space so installed apps follow updates; bundled copy as fallback. const MODEL_VERSION_URL = 'https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/model-version.json'; // { version, version_4bit }: version_4bit is used by the 4-bit builds (graphics chips without 16-bit maths), because // v0.3 does not survive 4-bit rounding and v0.6 (trained 4-bit-aware) does. Apps before 0.5.0 read only "version". const pickVersions = j => { const ok = v => typeof v === 'string' && /^v\d+\.\d+$/.test(v); return ok(j.version) ? { version: j.version, version_4bit: ok(j.version_4bit) ? j.version_4bit : j.version } : undefined; }; ipcMain.handle('model:version', async () => { const local = () => { try { return pickVersions(JSON.parse(fs.readFileSync(path.join(__dirname, 'model-version.json'), 'utf8'))); } catch { return undefined; } }; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); const res = await fetch(MODEL_VERSION_URL, { signal: controller.signal, cache: 'no-store' }); clearTimeout(timer); if (res.ok) { const v = pickVersions(await res.json()); if (v) return v; } } catch { /* offline: use the bundled version */ } return local(); }); // Vulnerability data: the downloaded copy first, then the one shipped with the app (or built locally during development). const vulndbDirs = () => [path.join(app.getPath('userData'), 'vulndb'), path.join(AUDIT_ROOT, 'vulndb', 'out')]; let mainWindow = null; // Automated check of a build: DEVSEIS_SMOKE= runs a non-elevated audit without the model and saves the report there. const SMOKE = process.env.DEVSEIS_SMOKE || ''; function smokeTest() { const js = code => mainWindow.webContents.executeJavaScript(code); const until = async (code, ms) => { const t = Date.now(); while (!(await js(code).catch(() => false))) { if (Date.now() - t > ms) throw new Error(`smoke timeout: ${code}`); await new Promise(r => setTimeout(r, 500)); } }; mainWindow.webContents.once('did-finish-load', async () => { try { await until(`!document.getElementById('screen-approve').classList.contains('hidden')`, 3600000); console.log('SMOKE model:', await js(`document.getElementById('model-name').textContent`)); await js(`document.getElementById('elevated').checked = false; document.getElementById('run').click()`); await until(`!document.getElementById('save-modal').classList.contains('hidden')`, 3600000); console.log('SMOKE result:', (await js(`document.getElementById('result-summary').innerText`)).replace(/\s+/g, ' ').slice(0, 600)); await js(`document.getElementById('save-docs').click()`); await until(`/Saved|saved/.test(document.getElementById('saved-note').textContent)`, 120000); console.log('SMOKE fixes:', (await js(`document.getElementById('fixes').innerText`)).replace(/\s+/g, ' ').slice(0, 900)); fs.writeFileSync(path.join(SMOKE, 'smoke-results.png'), (await mainWindow.webContents.capturePage()).toPNG()); // DEVSEIS_FIX_DRY=1: click Fix selected and confirm; the fix script only prints what it would change (no root, no change). if (process.env.DEVSEIS_FIX_DRY && await js(`!!document.getElementById('fix-selected')`)) { await js(`document.getElementById('fix-selected').click()`); console.log('SMOKE confirm:', (await js(`document.getElementById('fix-confirm-list').innerText`)).replace(/\s+/g, ' ').slice(0, 600)); await js(`document.getElementById('fix-go').click()`); await until(`!document.getElementById('fix-banner').classList.contains('hidden')`, 3600000); console.log('SMOKE fix banner:', (await js(`document.getElementById('fix-banner').innerText`)).replace(/\s+/g, ' ').slice(0, 900)); await js(`document.getElementById('save-modal').classList.add('hidden'); 0`); fs.writeFileSync(path.join(SMOKE, 'smoke-fixed.png'), (await mainWindow.webContents.capturePage()).toPNG()); } console.log('SMOKE OK', await js(`document.getElementById('saved-note').textContent`)); app.exit(0); } catch (error) { console.log('SMOKE FAILED', error.message); app.exit(1); } }); } function createWindow() { mainWindow = new BrowserWindow({ width: 1100, height: 780, minWidth: 760, minHeight: 560, title: 'Arx', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true }, }); // DEVSEIS_SMOKE_MODEL=1 also loads the AI model (downloads it on first run). mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'), SMOKE && !process.env.DEVSEIS_SMOKE_MODEL ? { search: 'smoke=1' } : undefined); if (SMOKE) smokeTest(); } // One Arx at a time: a second launch (a double-click while Arx is still starting or closing) hands over to the running // copy, which shows its window, and exits; two copies could leave one stuck in the background with no window. const firstInstance = !!SMOKE || app.requestSingleInstanceLock(); if (!firstInstance) app.quit(); app.on('second-instance', () => { if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; } if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); }); app.whenReady().then(() => { if (!firstInstance) return; createWindow(); // Fetch newer vulnerability data in the background; the audit uses the last good copy if this fails. vulndb.refresh(vulndbDirs()[0]).catch(error => console.warn(`Vulnerability data not refreshed: ${error.message}`)); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); // Closing the Arx window quits Arx, on macOS too: users expect a closed utility to be gone, not running unseen. app.on('window-all-closed', () => app.quit()); // --- What will be audited ------------------------------------------------------- ipcMain.handle('audit:plan', () => { const catalog = readJson('catalog', 'checks.json'); const sections = catalog.sections.map(section => ({ id: section.id, title: section.title, checks: catalog.checks.filter(c => c.section === section.id && c.os.includes(OS_FAMILY)) .map(c => ({ id: c.id, name: (c.names && c.names[OS_FAMILY]) || c.name })), })); return { os: OS_FAMILY, hostname: os.hostname(), platform: `${os.type()} ${os.release()}`, sections }; }); ipcMain.handle('audit:library', () => ({ catalog: readJson('catalog', 'checks.json'), controls: readJson('library', 'controls.json'), guidance: readJson('library', 'check_guidance.json'), aiTools: readJson('library', 'ai_tools.json'), fixes: readJson('library', 'fixes.json').fixes, })); // --- Fixes ------------------------------------------------------------------------------- // Only ids that library/fixes.json marks as 'auto' for this OS reach the fix script; nothing else is passed to a shell. function autoFixIds(ids) { const fixes = readJson('library', 'fixes.json').fixes; return [...new Set(ids)].filter(id => typeof id === 'string' && fixes[id] && fixes[id][OS_FAMILY] && fixes[id][OS_FAMILY].kind === 'auto'); } const FIX_SCRIPT = { macos: 'fix.sh', linux: 'fix.sh', windows: 'fix.ps1' }; // Test only (smoke test): the fix script prints what it would change and changes nothing; no administrator prompt. const FIX_DRY = !!(process.env.DEVSEIS_SMOKE && process.env.DEVSEIS_FIX_DRY); const fixHistoryFile = () => path.join(app.getPath('userData'), 'fix-history.json'); function fixHistory() { try { return JSON.parse(fs.readFileSync(fixHistoryFile(), 'utf8')); } catch { return []; } } ipcMain.handle('fix:history', () => fixHistory().slice(-20)); ipcMain.handle('fix:settings', (event, checkId) => { const fix = (readJson('library', 'fixes.json').fixes[checkId] || {})[OS_FAMILY]; if (fix && fix.kind === 'settings' && /^x-apple\.systempreferences:|^ms-settings:|^windowsdefender:/.test(fix.url)) return shell.openExternal(fix.url); return false; }); // --- Running the collector ---------------------------------------------------------- function collectorCommand(workDir, progressFile, elevated, fix = null) { const base = settingsFile('baseline.conf'); const org = settingsFile('organisational.conf'); if (OS_FAMILY === 'windows') { const q = s => `'${s.replace(/'/g, "''")}'`; const runner = path.join(workDir, 'run.ps1'); // A fix run: apply or undo first (old values saved in the fix state file), then audit again to show the result. const fixStep = fix ? `& ${q(path.join(AUDIT_ROOT, 'collectors', 'windows', FIX_SCRIPT.windows))} -Action ${fix.action} -State ${q(path.join(app.getPath('userData'), 'fix-state.tsv'))} -Results ${q(path.join(workDir, 'fix-results.tsv'))} -Baseline ${q(base)}${FIX_DRY ? ' -DryRun' : ''} -Ids @(${fix.ids.map(q).join(', ')}) *> ${q(path.join(workDir, 'fix.log'))}\n` : ''; fs.writeFileSync(runner, `${fixStep}& ${q(path.join(AUDIT_ROOT, 'collectors', 'windows', 'audit.ps1'))} -Baseline ${q(base)} -Organisational ${q(org)} -Output ${q(workDir)} -Progress ${q(progressFile)} *> ${q(path.join(workDir, 'collector.log'))}\n`); const inner = `-NoProfile -ExecutionPolicy Bypass -File "${runner}"`; if (!elevated) return ['powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', runner]]; // UAC prompt; -Wait keeps this process alive until the elevated audit finishes. return ['powershell.exe', ['-NoProfile', '-Command', `Start-Process powershell.exe -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList '${inner.replace(/'/g, "''")}'`]]; } const q = s => `'${s.replace(/'/g, `'\\''`)}'`; const runner = path.join(workDir, 'run.sh'); // Development runs read the scripts from the repository, often under ~/Documents, which macOS privacy protection // (TCC) hides from the administrator process ("Operation not permitted"). Run copies from the temporary folder instead; // a packaged app keeps them inside the app bundle, which is not protected. let scripts = path.join(AUDIT_ROOT, 'collectors', OS_FAMILY); if (!app.isPackaged) { const copy = path.join(workDir, 'collectors'); fs.cpSync(scripts, copy, { recursive: true }); scripts = copy; } const collector = path.join(scripts, 'audit.sh'); // A fix run: apply or undo first (old values saved in the fix state file), then audit again to show the result. const fixStep = fix ? `/bin/bash ${q(path.join(scripts, FIX_SCRIPT[OS_FAMILY]))} ${fix.action}${FIX_DRY ? ' --dry-run' : ''} --state ${q(path.join(app.getPath('userData'), 'fix-state.tsv'))} --baseline ${q(base)} ${fix.ids.map(q).join(' ')} > ${q(path.join(workDir, 'fix-results.tsv'))} 2>&1\n` : ''; fs.writeFileSync(runner, `#!/bin/bash\n${fixStep}/bin/bash ${q(collector)} --baseline ${q(base)} --org ${q(org)} --output ${q(workDir)} --progress ${q(progressFile)} > ${q(path.join(workDir, 'collector.log'))} 2>&1\nchmod -R a+rX ${q(workDir)}\n`, { mode: 0o755 }); if (!elevated) return ['/bin/bash', [runner]]; if (OS_FAMILY === 'macos') { // macOS administrator prompt; the runner path has no quotes or backslashes. const prompt = fix ? `Arx needs administrator rights to ${fix.action === 'undo' ? 'undo' : 'apply'} ${fix.ids.length} fix${fix.ids.length > 1 ? 'es' : ''} you chose, then check this Mac again.` : 'Arx needs administrator rights to read security settings. It only reads settings and changes nothing.'; return ['/usr/bin/osascript', ['-e', `do shell script "/bin/bash '${runner}'" with prompt "${prompt}" with administrator privileges`]]; } return ['pkexec', ['/bin/bash', runner]]; } ipcMain.handle('audit:run', async (event, { elevated, fix: fixRequest }) => { let fix = null; if (fixRequest) { if (!FIX_SCRIPT[OS_FAMILY]) throw new Error('Automatic fixes are not available on this system yet.'); const ids = autoFixIds(fixRequest.ids || []); if (!ids.length || !['apply', 'undo'].includes(fixRequest.action)) throw new Error('No fix to run.'); fix = { action: fixRequest.action, ids }; elevated = !FIX_DRY; } const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devseis-audit-')); fs.chmodSync(workDir, 0o755); const progressFile = path.join(workDir, 'progress.tsv'); fs.writeFileSync(progressFile, ''); fs.chmodSync(progressFile, 0o666); const [command, args] = collectorCommand(workDir, progressFile, elevated, fix); return new Promise((resolve, reject) => { let offset = 0; let done = null; const poll = () => { const text = fs.readFileSync(progressFile, 'utf8'); const fresh = text.slice(offset); const complete = fresh.lastIndexOf('\n') + 1; offset += complete; for (const line of fresh.slice(0, complete).split('\n').filter(Boolean)) { const [first, second, third] = line.split('\t'); if (first === 'DONE') done = { evidencePath: second, reportPath: third }; else event.sender.send('audit:progress', { id: first, status: second, name: third }); } }; const timer = setInterval(poll, 250); const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk; }); child.on('error', error => { clearInterval(timer); reject(new Error(`Could not start the audit: ${error.message}`)); }); child.on('close', code => { clearInterval(timer); poll(); if (!done) { const log = fs.existsSync(path.join(workDir, 'collector.log')) ? fs.readFileSync(path.join(workDir, 'collector.log'), 'utf8').slice(-600) : ''; const cancelled = /User canceled|cancelled|-128|Request dismissed|126/.test(stderr) || code === 126; reject(new Error(cancelled ? 'The administrator prompt was cancelled.' : `The audit did not finish (exit ${code}). ${stderr.slice(-300)} ${log}`.trim())); return; } let evidenceText = fs.readFileSync(done.evidencePath, 'utf8'); let evidence = JSON.parse(evidenceText.replace(/^/, '')); // Match the software inventory against the offline vulnerability data (on this computer only). const inventoryPath = done.evidencePath.replace(/\.json$/, '-inventory.json'); if (fs.existsSync(inventoryPath)) { try { const inventory = JSON.parse(fs.readFileSync(inventoryPath, 'utf8').replace(/^/, '')); const maxSevere = parseInt((evidence.baseline || {}).MAX_CRITICAL_HIGH_VULNERABILITIES || '0', 10) || 0; evidence = vulndb.apply(evidence, inventory, vulndb.load(vulndbDirs()), maxSevere); evidenceText = JSON.stringify(evidence, null, 2); const r = evidence.results.find(x => x.id === 'vuln.known_vulnerabilities'); if (r) event.sender.send('audit:progress', { id: r.id, status: r.status, name: r.name }); } catch (error) { console.warn(`Vulnerability matching skipped: ${error.message}`); } } let fixResults = null; if (fix) { // (a dry run is a test: it is reported but never recorded as something to undo) const raw = fs.existsSync(path.join(workDir, 'fix-results.tsv')) ? fs.readFileSync(path.join(workDir, 'fix-results.tsv'), 'utf8') : ''; fixResults = raw.split('\n').filter(l => l.startsWith('RESULT\t')).map(l => { const [, id, outcome, message] = l.split('\t'); return { id, outcome, message }; }); const history = FIX_DRY ? null : fixHistory(); if (history) history.push({ at: new Date().toISOString(), action: fix.action, results: fixResults }); if (history) try { fs.writeFileSync(fixHistoryFile(), JSON.stringify(history.slice(-50), null, 1)); } catch { /* history is a convenience */ } } resolve({ evidence, evidenceText, sha256: crypto.createHash('sha256').update(evidenceText).digest('hex'), workDir, fixResults }); }); }); }); // --- Saving the report ------------------------------------------------------------------ function defaultFolder(hostname) { const d = new Date(); const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-` + `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`; return path.join(SMOKE || app.getPath('documents'), 'Arx Reports', `${hostname.replace(/[^\w.-]/g, '_')}-${stamp}`); } async function renderPdf(html) { const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, javascript: false } }); await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); const pdf = await win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 } }); win.destroy(); return pdf; } // mode: 'choose' opens a Save dialog; 'auto' saves straight to the Documents folder. ipcMain.handle('report:save', async (event, { html, evidenceText, hostname, mode }) => { const pdf = await renderPdf(html); let pdfPath; if (mode === 'choose') { const folder = defaultFolder(hostname); const result = await dialog.showSaveDialog(mainWindow, { title: 'Save the audit report', defaultPath: path.join(folder, 'audit-report.pdf'), filters: [{ name: 'PDF', extensions: ['pdf'] }], }); if (result.canceled || !result.filePath) return { saved: false }; pdfPath = result.filePath; } else { pdfPath = path.join(defaultFolder(hostname), 'audit-report.pdf'); } fs.mkdirSync(path.dirname(pdfPath), { recursive: true }); fs.writeFileSync(pdfPath, pdf); // Keep the machine-readable evidence next to the PDF (it is personal data: it stays on this computer). fs.writeFileSync(pdfPath.replace(/\.pdf$/i, '') + '-evidence.json', evidenceText); return { saved: true, pdfPath }; }); ipcMain.handle('report:show', (event, filePath) => shell.showItemInFolder(filePath)); // The Devseis security assistant (the Space's chat page) in an app window, with this audit attached. // The report is handed over inside the app (the page's local storage); the page never uploads it. const CHAT_URL = 'https://devseis-arx.static.hf.space/index.html'; let chatWindow = null; ipcMain.handle('chat:open', async (event, evidenceText) => { if (chatWindow && !chatWindow.isDestroyed()) chatWindow.close(); chatWindow = new BrowserWindow({ width: 1000, height: 820, minWidth: 420, minHeight: 560, title: 'Arx · report', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }); chatWindow.webContents.setWindowOpenHandler(({ url }) => (url.startsWith('blob:') ? { action: 'allow' } : (shell.openExternal(url), { action: 'deny' }))); try { await chatWindow.loadURL(CHAT_URL); await chatWindow.webContents.executeJavaScript(`localStorage.setItem('devseis.lastAudit', ${JSON.stringify(evidenceText)}); true`); await chatWindow.loadURL(`${CHAT_URL}?from=app#audit`); return { opened: true }; } catch (error) { if (!chatWindow.isDestroyed()) chatWindow.close(); return { opened: false, error: `The assistant needs an internet connection the first time (${error.message}).` }; } });