Arx / mobile /app.js
umer-wasim's picture
Rename to Arx (display name) with a new fortress icon; knowledge base: Who is Arx
f42ab02 verified
Raw History Blame Contribute Delete
20.8 kB
// Devseis Phone Check: browser detection + guided questions -> evidence (mobile-core.js) -> findings written by
// the auditor model in WebLLM (or the built-in writer) -> report the user can save or share as PDF.
// Nothing about the phone leaves the browser; only the model and the vulnerability data are downloaded.
import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from '../app/renderer/auditor-core.js';
import { buildReportHtml } from '../app/renderer/report.js';
import { loadModelWith } from '../app/renderer/model-core.js';
import { buildMobileEvidence, questionsFor } from './mobile-core.js';
import { detectDevice } from './detect.js';
import { loadPhoneVulnData } from './vuln-web.js';
const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85';
const HTML2PDF_URL = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
const MODEL_SIZE = 'about 350 MB, downloaded once';
const $ = id => document.getElementById(id);
const show = id => { document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); $(`s-${id}`).classList.remove('hidden'); window.scrollTo(0, 0); };
const setBar = (id, fraction) => { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; };
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const state = { device: null, data: null, lib: null, steps: [], index: 0, answers: {}, aiApps: [], version: null, versionEdited: false,
model: null, modelPromise: null, modelStatus: '', vulnPromise: null, report: null };
function readConf(text) {
const conf = {};
for (const line of text.split('\n')) {
if (!line.includes('=') || line.trimStart().startsWith('#')) continue;
const i = line.indexOf('=');
conf[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^"|"$/g, '');
}
return conf;
}
// Organisation answers can be passed by the employer in the link: #org=<base64 JSON {ORG_ROPA: "yes", ...}>
function orgFromLink() {
const m = /[#&]org=([^&]+)/.exec(location.hash);
if (!m) return null;
try { return JSON.parse(atob(decodeURIComponent(m[1]).replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
}
async function loadData() {
const get = async (path, type = 'json') => { const r = await fetch(path); if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`); return type === 'json' ? r.json() : r.text(); };
const [catalog, questions, controls, guidance, aiTools, baselineText] = await Promise.all([
get('catalog/checks.json'), get('catalog/mobile_questions.json'), get('library/controls.json'), get('library/check_guidance.json'),
get('library/ai_tools.json'), get('baseline.conf', 'text')]);
return { catalog, questions, baseline: readConf(baselineText), library: { catalog, controls, guidance, aiTools } };
}
// --- 1. Start ---------------------------------------------------------------------------------
function renderDevice() {
const d = state.device;
const rows = [['Device', d.device_model || (d.os_family ? 'Phone' : 'Computer')],
['System', d.os_name ? `${d.os_name} ${d.os_version || '(version not visible)'}` : 'Not a phone'],
['Browser', d.browser],
['Screen lock', d.platform_authenticator === true ? 'Unlock method detected' : d.platform_authenticator === false ? 'Not detected (will ask)' : 'Unknown (will ask)'],
['AI on this device', d.webgpu ? 'Supported (WebGPU)' : 'Not supported; the built-in writer is used']];
$('device-card').innerHTML = rows.map(([k, v]) => `<b>${esc(k)}</b><span>${esc(v)}</span>`).join('');
$('desktop-note').classList.toggle('hidden', !d.desktop);
if (d.desktop && !d.os_family) {
// On a computer, lead with the desktop app: a web page cannot read a computer's settings.
const mac = /Macintosh|Mac OS X/.test(navigator.userAgent), win = /Windows/.test(navigator.userAgent);
const system = mac ? 'macOS' : win ? 'Windows' : 'Linux';
$('hero-title').textContent = 'Audit this computer';
$('hero-lead').innerHTML = 'A free, read-only audit against <b>ISO 27001</b>, <b>GDPR</b> and the <b>EU AI Act</b>. The Devseis AI auditor writes the report <b>on your computer</b>; nothing about it is uploaded.';
$('device-card').innerHTML = [['Device', 'Computer'], ['System', system], ['Browser', d.browser]].map(([k, v]) => `<b>${esc(k)}</b><span>${esc(v)}</span>`).join('');
$('facts').classList.add('hidden');
$('start').classList.add('hidden');
$('dl-desktop').textContent = `Download the desktop app for ${mac ? 'Mac' : system}${win ? ' (beta)' : ''}`;
$('dl-desktop').href = `download.html#${mac ? 'mac' : win ? 'windows' : 'linux'}`;
$('desktop-other').innerHTML = (mac ? 'macOS 13 or later, Apple silicon and Intel.' : win ? 'Windows 10 / 11, 64-bit. Beta: not yet tested on a real Windows PC.' : 'x86_64 AppImage or .deb, tested on Ubuntu 24.04.')
+ ' <a href="download.html">All downloads and install steps</a>.';
} else if (d.os_family) {
$('hero-title').textContent = 'Is this phone ready for work?';
$('hero-lead').innerHTML = 'A free check against <b>ISO 27001</b>, <b>GDPR</b> and the <b>EU AI Act</b>, written by the Devseis AI auditor <b>inside your browser</b>.';
$('facts').classList.remove('hidden');
$('start').classList.remove('hidden');
}
$('start').disabled = !d.os_family;
}
function demo(osFamily) {
Object.assign(state.device, osFamily === 'ios'
? { os_family: 'ios', os_name: 'iOS', os_version: null, version_precise: false, device_model: 'iPhone (demo on a computer)', platform_authenticator: null }
: { os_family: 'android', os_name: 'Android', os_version: null, version_precise: false, device_model: 'Android phone (demo on a computer)', platform_authenticator: null });
renderDevice();
}
// --- 2. Approval --------------------------------------------------------------------------------
function showConsent() {
const { catalog } = state.data;
const os = state.device.os_family;
const checks = catalog.checks.filter(c => c.os.includes(os));
$('check-count').textContent = checks.length;
$('plan').innerHTML = checks.map(c => `<li>${esc((c.names && c.names[os]) || c.name)}</li>`).join('');
const ai = $('use-ai');
ai.disabled = !state.device.webgpu;
ai.checked = state.device.webgpu && !state.device.cellular;
$('ai-note').textContent = !state.device.webgpu
? 'This browser has no WebGPU, so the built-in report writer is used.'
: `Runs on this phone (${MODEL_SIZE}; Wi-Fi recommended). Off = built-in report writer.`;
show('consent');
}
function startBackgroundWork() {
state.vulnPromise = loadPhoneVulnData();
if ($('use-ai').checked) {
const chip = $('model-chip');
chip.classList.remove('hidden');
chip.textContent = 'AI auditor: starting download';
const version = fetch('app/model-version.json', { cache: 'no-cache' }).then(r => r.json()).then(j => j.version).catch(() => undefined);
state.modelPromise = Promise.all([import(WEBLLM_URL), version])
.then(([webllm, v]) => loadModelWith(webllm, p => { chip.textContent = `AI auditor: ${Math.round((p.progress || 0) * 100)}%`; }, 'compact', v))
.then(model => { state.model = model; chip.textContent = 'AI auditor ready'; return model; })
.catch(error => { chip.textContent = 'AI unavailable: built-in writer'; state.modelStatus = error.message; return null; });
} else {
state.modelPromise = Promise.resolve(null);
}
}
// --- 3. Questions -------------------------------------------------------------------------------
function buildSteps() {
const { catalog, questions } = state.data;
const d = state.device;
const steps = [{ kind: 'version', ...questions.os_version[d.os_family] }];
for (const q of questionsFor(d.os_family, catalog, questions, { platform_authenticator: d.platform_authenticator })) steps.push({ kind: 'check', ...q });
steps.push({ kind: 'ai', name: 'AI software inventory', ...questions.ai_apps });
return steps;
}
function renderStep() {
const step = state.steps[state.index];
const total = state.steps.length;
setBar('q-bar', state.index / total);
$('q-count').textContent = `Question ${state.index + 1} of ${total}`;
$('q-check').textContent = step.kind === 'version' ? 'System version' : step.name;
$('q-text').textContent = step.question;
$('q-where').textContent = step.where;
const box = $('q-answers');
box.innerHTML = '';
const next = () => { state.index += 1; state.index < total ? renderStep() : finish(); };
if (step.kind === 'version') {
const input = document.createElement('input');
input.type = 'text'; input.inputMode = 'decimal'; input.placeholder = state.device.os_family === 'ios' ? 'for example 27.0.1' : 'for example 15';
input.value = state.version || state.device.os_version || '';
const hint = document.createElement('p');
hint.className = 'muted';
hint.textContent = state.device.os_version ? `Detected by the browser: ${state.device.os_version}. Correct it if Settings shows more detail.` : 'The browser could not see the version; please type it.';
const ok = button('Continue', 'primary', () => {
const value = input.value.trim();
if (!/^\d+(\.\d+){0,2}$/.test(value)) { input.focus(); return; }
state.version = value;
state.versionEdited = value !== state.device.os_version;
next();
});
box.append(hint, input, ok);
return;
}
if (step.kind === 'ai') {
for (const app of step.options) {
const label = document.createElement('label');
label.className = 'multi';
label.innerHTML = `<input type="checkbox" value="${esc(app)}" ${state.aiApps.includes(app) ? 'checked' : ''}><span>${esc(app)}</span>`;
box.append(label);
}
box.append(button('Continue', 'primary', () => {
state.aiApps = [...box.querySelectorAll('input:checked')].map(i => i.value);
next();
}));
return;
}
if (step.input === 'month') {
const input = document.createElement('input');
input.type = 'month';
input.value = typeof state.answers[step.id] === 'string' && state.answers[step.id] !== 'not_sure' ? state.answers[step.id] : '';
box.append(input, button('Continue', 'primary', () => { if (!/^\d{4}-\d{2}$/.test(input.value)) { input.focus(); return; } state.answers[step.id] = input.value; next(); }));
} else {
step.answers.forEach((answer, i) => box.append(button(answer.label, 'secondary', () => { state.answers[step.id] = i; next(); })));
}
box.append(button(state.data.questions.not_sure.label, 'secondary not-sure', () => { state.answers[step.id] = 'not_sure'; next(); }));
}
function button(text, className, onClick) {
const b = document.createElement('button');
b.className = className; b.textContent = text; b.type = 'button';
b.addEventListener('click', onClick);
return b;
}
// --- 4. Evidence and report -----------------------------------------------------------------------
async function finish() {
show('work');
const d = state.device;
$('w-step').textContent = 'Checking the vulnerability data';
setBar('w-bar', 0.02);
const vuln = await state.vulnPromise;
const now = new Date();
const offset = -now.getTimezoneOffset();
const pad = n => String(Math.abs(n)).padStart(2, '0');
const started = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offset >= 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
const input = {
os_family: d.os_family, os_name: d.os_name, os_version: state.version, version_source: state.versionEdited || !d.os_version ? 'user' : 'browser',
browser: d.browser, device_model: d.device_model || (d.os_family === 'ios' ? 'iPhone' : 'Android phone'), hostname: d.device_model || d.os_name,
started_at: started, platform_authenticator: d.platform_authenticator, answers: state.answers, ai_apps: state.aiApps, org: orgFromLink(),
};
const evidence = buildMobileEvidence(input, { catalog: state.data.catalog, questions: state.data.questions, baseline: state.data.baseline, vuln, now: now.getTime() });
const evidenceText = JSON.stringify(evidence, null, 2);
const sha256 = [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(evidenceText)))].map(b => b.toString(16).padStart(2, '0')).join('');
if (state.modelPromise && !state.model) {
$('w-title').textContent = 'Getting the AI auditor ready';
$('w-step').textContent = 'Finishing the model download (only the first time)…';
await state.modelPromise;
}
const model = state.model;
const lib = state.lib;
const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status) && r.section !== 'org');
const ai = aiPrompt(evidence, lib);
const total = targets.length + 1 + (ai ? 1 : 0);
let done = 0;
const sources = { model: 0, template: 0 };
const generate = model ? model.generate : null;
const tick = label => { done += 1; setBar('w-bar', done / total); $('w-current').textContent = label; };
$('w-title').textContent = 'Writing your report';
$('w-step').textContent = model ? `The AI auditor is writing ${total} parts on this phone` : 'The built-in writer is writing the findings';
const findings = {};
for (const r of evidence.results.filter(x => x.section === 'org' && x.status !== 'Compliant')) {
findings[r.id] = findingTemplate(evidence, r, lib); // organisational answers: short, fixed wording
}
for (const r of targets) {
$('w-current').textContent = `Writing: ${r.name}`;
const out = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate);
findings[r.id] = out.answer;
sources[out.source] += 1;
tick(`Written: ${r.name}`);
}
const summaryBase = summaryTemplate(evidence, lib);
const summaryOut = await writeAnswer('summary', summaryPrompt(evidence, lib), summaryBase, generate);
const summary = summaryOut.source === 'model' ? { ...summaryBase, summary: summaryOut.answer.summary, next_steps: summaryOut.answer.next_steps } : summaryBase;
sources[summaryOut.source] += 1;
tick('Written: summary');
let aiResult = null;
if (ai) {
const out = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate);
aiResult = out.answer;
sources[out.source] += 1;
tick('Written: AI app classification');
}
const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256, model: model ? model.name : null, sources });
state.report = { html, evidence, evidenceText, summary, sources };
showResult();
}
// --- 5. Result, PDF and sharing ----------------------------------------------------------------
function showResult() {
const { evidence, summary, sources } = state.report;
const pills = Object.entries(evidence.summary).filter(([, v]) => v).map(([k, v]) => `<span class="pill s-${k}">${v} ${k}</span>`).join('');
const gaps = summary.top_gaps.map(g => `<li><b class="s-Non-Compliant">${esc(g.risk)}</b> — ${esc(g.name)}: ${esc(g.found)}</li>`).join('');
const reported = evidence.results.filter(r => r.evidence === 'self-reported').length;
const detected = evidence.results.filter(r => r.evidence === 'detected').length;
$('result').innerHTML = `
<h1>Your phone check</h1>
<div class="card"><div class="score">${summary.score.percent}%</div>
<p class="muted">${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant · ${detected} detected, ${reported} self-reported</p>
<div class="pills">${pills}</div>
<p>${esc(summary.summary)}</p>
${gaps ? `<b>Top gaps</b><ul class="gaps">${gaps}</ul>` : ''}
<p class="muted">Written ${sources.model ? `by the AI auditor (${sources.model}) and ` : ''}by the built-in writer (${sources.template}).</p>
</div>`;
$('pdf-note').textContent = 'The report stays on this phone until you save or share it.';
try { localStorage.setItem('devseis.lastAudit', state.report.evidenceText); } catch { /* private mode */ }
if (!document.getElementById('ask-assistant')) {
document.querySelector('#s-done .actions').insertAdjacentHTML('afterbegin', '<a class="dl-mac" id="ask-assistant" href="index.html#audit">💬 Ask Arx about this report</a>');
}
show('done');
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if ([...document.scripts].some(s => s.src === src)) return resolve();
const s = document.createElement('script');
s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('could not load the PDF tool'));
document.head.append(s);
});
}
async function makePdf() {
await loadScript(HTML2PDF_URL);
const doc = new DOMParser().parseFromString(state.report.html, 'text/html');
const holder = document.createElement('div');
holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:794px;background:#fff;color:#16181d';
const style = document.createElement('style');
style.textContent = doc.querySelector('style').textContent.replace(/(^|\n)\s*body\s*\{/g, '$1.devseis-report {');
const body = document.createElement('div');
body.className = 'devseis-report';
body.innerHTML = doc.body.innerHTML;
holder.append(style, body);
document.body.append(holder);
try {
return await window.html2pdf().set({ margin: 10, image: { type: 'jpeg', quality: 0.92 }, html2canvas: { scale: 1.5, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4' }, pagebreak: { mode: ['css', 'legacy'] } }).from(body).outputPdf('blob');
} finally {
holder.remove();
}
}
function fileName(ext) {
const e = state.report.evidence;
return `devseis-phone-check-${e.host.os_family}-${e.audit.started_at.slice(0, 10)}.${ext}`;
}
function download(blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name;
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
}
async function sharePdf() {
$('pdf-note').textContent = 'Preparing the PDF…';
try {
const blob = await makePdf();
const file = new File([blob], fileName('pdf'), { type: 'application/pdf' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: 'Arx phone check by Devseis' });
$('pdf-note').textContent = 'Shared. Choose "Save to Files" in the share sheet to keep a copy on the phone.';
return;
} catch (error) {
if (error.name === 'AbortError') { $('pdf-note').textContent = 'Sharing cancelled.'; return; }
}
}
download(blob, file.name);
$('pdf-note').textContent = `Saved as ${file.name} (Downloads).`;
} catch (error) {
$('pdf-note').textContent = `The PDF could not be made (${error.message}). Use "View full report" and your browser's Share › Print to save it as PDF.`;
}
}
function viewReport() {
const url = URL.createObjectURL(new Blob([state.report.html], { type: 'text/html' }));
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
// --- Wiring ----------------------------------------------------------------------------------------
async function init() {
try {
[state.device, state.data] = await Promise.all([detectDevice(), loadData()]);
} catch (error) {
$('device-card').innerHTML = `<span class="s-Error">The check could not start: ${esc(error.message)}</span>`;
return;
}
state.lib = new Library(state.data.library);
renderDevice();
}
$('start').addEventListener('click', showConsent);
document.querySelectorAll('[data-demo]').forEach(b => b.addEventListener('click', () => demo(b.dataset.demo)));
$('consent-back').addEventListener('click', () => show('start'));
$('consent-ok').addEventListener('click', () => {
startBackgroundWork();
state.steps = buildSteps();
state.index = 0;
show('question');
renderStep();
});
$('q-back').addEventListener('click', () => { if (state.index > 0) { state.index -= 1; renderStep(); } else show('consent'); });
$('share-pdf').addEventListener('click', sharePdf);
$('view-report').addEventListener('click', viewReport);
$('save-json').addEventListener('click', () => download(new Blob([state.report.evidenceText], { type: 'application/json' }), fileName('json')));
$('again').addEventListener('click', () => { state.answers = {}; state.aiApps = []; state.version = null; show('start'); });
init();
// Automated tests only (?test in the URL): expose state and the PDF maker.
if (new URLSearchParams(location.search).has('test')) Object.assign(window, { __devseisState: state, __devseisMakePdf: makePdf });