Arx / app /main.cjs
umer-wasim's picture
Arx 0.5.6: password/lockout fixes on Mac, Linux, Windows; Fix problems on Windows (fix.ps1); Linux pwquality read order
458b1d7 verified
Raw History Blame Contribute Delete
21.2 kB
// 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/<computer>-<date>/.
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=<folder> 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}).` };
}
});