Antivirus/gui/dist/app.js
dev 3b3586b60a gui: make the desktop app actually launch, and let it reach the daemon
The start-menu entry ran `hound` with Terminal=true — the CLI, which
printed help and exited. No GUI binary had ever been built or packaged.
Four separate faults were stacked behind that report:

1. build-deb.sh now builds and ships hound-gui, and writes a .desktop
   entry only when that binary exists. A launcher for software that is
   not there is worse than no launcher.

2. Tray icons were loaded from a relative "icons/" path, which resolves
   only from the build tree. Installed to /usr/bin the setup hook failed
   and Tauri panicked before a window appeared. They are include_bytes!
   now — four ~1 KB PNGs that can no longer be missing.

3. The front-end never ran at all. app.js opened with a bare module
   specifier ("@tauri-apps/api/core") and there is no bundler, so the
   webview could not resolve it and the script silently failed to parse.
   The window rendered its static HTML forever, which looks exactly like
   a daemon that never answered. withGlobalTauri + window.__TAURI__.

4. build-deb.sh ran the Tauri build as `>/dev/null 2>&1 || true`, so a
   config error scrolled past unseen and the package shipped the
   PREVIOUS binary. Two fixes appeared to do nothing. That step is no
   longer silenced or tolerant of failure, and the build fails outright
   on a bare import in gui/dist/*.js.

Guards, because each of these failed quietly: index.html flips to an
interface-error message if app.js never sets a boot flag within 5s. An
antivirus showing "Protected - your system looks healthy" while its own
front-end is dead is the worst failure mode there is.

Then the window came up and could not reach the daemon: the socket was
0700 root:root. Widening it needed more than a chmod, because
quarantine.restore writes files back out as root — handing that to a
desktop group would hand out root. So the daemon now checks SO_PEERCRED
per method (crates/houndd/src/peer.rs):

  - group `hound`: status, settings.get, events, quarantine.list,
    rootkit.scan, persistence.scan
  - scan/supply.sweep: only paths the caller could read itself, decided
    by forking a child, dropping to the peer's uid, gid and
    supplementary groups, and asking access(2) — which honours ACLs and
    mount options, unlike anything reconstructed from mode bits
  - everything that writes: root, or the uid the daemon runs as

Unclassified methods fall into Admin, so a new mutating method fails
closed rather than becoming public by omission. The end-to-end socket
test caught that "root only" broke every developer run; the owner
clause collapses to "root" under the packaged root daemon and is
verified to do so.

CAP_SETUID/CAP_SETGID join the gate capability set for the readability
check. There was a test asserting CAP_SETUID must never be retained —
it is updated with the reasoning rather than deleted. The daemon
already holds CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH, so becoming
another user widens nothing that matters. The unit gains Group=hound so
the socket can be chgrp'd without CAP_CHOWN; it stays uid 0.

Also: the footer claimed "engine: ClamAV via Unix socket". It reports
what the daemon actually loaded, which has been yara-x since the engine
was replaced. Every error path in the front-end goes through explain(),
so a privilege refusal reads as "run it from a terminal: sudo hound …"
rather than "daemon error -32000".

358 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:49:58 -05:00

552 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Hound Antivirus — webview front-end.
// Thin view over houndd via Tauri commands; 6-tab layout.
// No bundler here — the webview loads this file directly, so a bare module
// specifier ("@tauri-apps/api/core") cannot be resolved and the entire script
// fails to parse. The window then sits on its static HTML forever, looking
// exactly like a daemon that never answered. The API comes off the global
// that `withGlobalTauri` installs instead.
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
const $ = (id) => document.getElementById(id);
// The daemon refuses anything that writes unless the caller is root, because
// quarantine restores files back out as root and that is not something group
// membership should confer. The app runs as you, so those actions need a
// terminal for now. Translate the wire error rather than showing it raw.
function explain(e) {
const raw = String(e && e.message ? e.message : e);
const m = raw.match(/([a-z.]+) requires administrator privileges/);
if (m) {
return "That needs administrator rights. Run it from a terminal: sudo hound " +
({
"settings.set": "settings …",
"update": "update",
"quarantine.add": "quarantine add <file>",
"quarantine.restore": "quarantine restore <id>",
"quarantine.remove": "quarantine remove <id>",
"events.clear": "alerts clear",
"realtime.set_enabled": "settings realtime on|off",
}[m[1]] || m[1]);
}
if (/cannot read .* as uid/.test(raw)) {
return raw.replace(/^.*?cannot read/, "Hound will not scan") +
" (it only scans files you could open yourself).";
}
return raw.replace(/^daemon error -?\d+: /, "");
}
const ICONS = {
protected: "state-protected-48.png",
scanning: "state-scanning-48.png",
threat: "state-threat-48.png",
paused: "state-paused-48.png",
};
const TITLES = {
protected: "Protected",
scanning: "Scanning…",
threat: "Threat Detected",
paused: "Paused",
};
const SUBS = {
protected: "Your system looks healthy.",
scanning: "Hound is working the queue.",
threat: "We found something that should not be there.",
paused: "Real-time protection is off.",
};
let currentState = "protected";
let busy = false;
let paused = false;
// ── Tabs ───────────────────────────────────────────────────────────
const LOADERS = {
quarantine: loadQuarantine,
realtime: loadRealtime,
rootkit: null, // on demand
alerts: loadAlerts,
settings: loadSettings,
};
function switchTab(name) {
document.querySelectorAll(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.tab === name));
document.querySelectorAll(".tab-panel").forEach((p) =>
p.classList.toggle("active", p.id === "panel-" + name));
const fn = LOADERS[name];
if (fn) fn().catch(() => {});
}
document.querySelectorAll(".tab").forEach((t) =>
t.addEventListener("click", () => switchTab(t.dataset.tab)));
// ── State rendering (hero + tray) ─────────────────────────────────
function setState(state) {
if (!ICONS[state]) state = "protected";
currentState = state;
$("hero").dataset.state = state;
$("shield-icon").src = ICONS[state];
$("hero-title").textContent = TITLES[state];
$("hero-sub").textContent = SUBS[state];
invoke("set_state", { state }).catch(() => {});
}
function setEngineDot(cls, label) {
$("engine-dot").className = "engine-dot " + cls;
$("engine-label").textContent = label;
}
function fmtDbAge(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
const days = Math.floor((Date.now() - t.getTime()) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "1 day ago";
if (days < 30) return `${days} days ago`;
const mo = Math.floor(days / 30);
return `${mo} month${mo > 1 ? "s" : ""} ago`;
}
function renderStatus(st) {
setEngineDot(st.engine_present ? "ok" : "bad",
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
$("pill-os").textContent = "OS: " + (st.os || "—");
$("pill-engine").textContent = "engine: " + (st.engine || "—");
// The footer used to claim ClamAV. It reports whatever the daemon actually
// loaded, which has been yara-x since the engine was replaced.
$("foot-engine").textContent = "engine: " + (st.engine || "—");
const pill = $("pill-db");
if (st.db) {
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
const staleDays = (Date.now() - new Date(st.db.updated_at).getTime()) / 86400000;
pill.classList.toggle("stale", staleDays > 3);
$("foot-ver").textContent = `houndd ${st.daemon_version} · ${st.db.file}`;
} else {
pill.textContent = "signatures: none";
pill.classList.add("stale");
$("foot-ver").textContent = `houndd ${st.daemon_version}`;
}
}
// ── Scans ──────────────────────────────────────────────────────────
async function doScan(path) {
if (busy) return;
busy = true;
setButtons(true);
$("scan-panel").classList.remove("hidden");
$("scan-panel").querySelector(".progress").classList.add("indeterminate");
$("scan-title").textContent = "Scanning…";
$("scan-target").textContent = path;
setState("scanning");
try {
const r = await invoke("scan", { path, recursive: true });
renderScanResult(r, path);
} catch (e) {
$("scan-note").textContent = "Scan failed: " + explain(e);
setState(paused ? "paused" : "protected");
} finally {
busy = false;
setButtons(false);
$("scan-panel").querySelector(".progress").classList.remove("indeterminate");
}
}
function renderScanResult(r, path) {
const clean = r.infected === 0;
setState(clean ? (paused ? "paused" : "protected") : "threat");
$("scan-title").textContent = clean
? "Scan complete — clean"
: `Scan complete — ${r.infected} threat${r.infected > 1 ? "s" : ""}`;
$("scan-note").textContent =
`${r.scanned.toLocaleString()} files scanned · ${r.clean.toLocaleString()} clean · ${r.infected.toLocaleString()} infected`;
$("progress-bar").style.width = "100%";
$("results-meta").textContent = `${r.scanned} files · ${path}`;
const body = $("results-body");
body.innerHTML = "";
if (clean) {
body.innerHTML =
`<div class="result-row clean">
<span class="sig">✔ All clear</span>
<span class="path">${r.scanned.toLocaleString()} files scanned, nothing flagged</span>
<span></span>
</div>`;
} else {
for (const f of r.found.slice(0, 50)) {
const div = document.createElement("div");
div.className = "result-row infected";
div.innerHTML =
`<span class="sig">✘ ${escapeHtml(f.virus)}</span>
<span class="path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</span>
<span></span>`;
body.appendChild(div);
}
if (r.found.length > 50) {
const more = document.createElement("div");
more.className = "muted";
more.style.padding = "4px 2px";
more.textContent = `…and ${r.found.length - 50} more`;
body.appendChild(more);
}
}
}
// ── Signature update ───────────────────────────────────────────────
async function doUpdate() {
if (busy) return;
busy = true;
setButtons(true);
$("update-panel").classList.remove("hidden");
const log = $("update-log");
log.className = "log";
log.textContent = "Running freshclam — this can take a minute…\n";
try {
const u = await invoke("update");
log.textContent = u.output.trim();
log.className = "log " + (u.ok ? "ok" : "fail");
if (u.status) renderStatus(u.status);
setState(paused ? "paused" : "protected");
} catch (e) {
log.textContent = "Update failed: " + explain(e);
log.className = "log fail";
} finally {
busy = false;
setButtons(false);
}
}
// ── Quarantine tab ─────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let v = bytes, i = -1;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v < 10 ? 1 : 0) + " " + units[i];
}
function fmtTime(iso) {
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return iso;
return t.toLocaleString();
}
function setQuarantineBadge(n) {
const b = $("badge-quarantine");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadQuarantine() {
try {
const list = await invoke("quarantine_list");
const body = $("qt-body");
body.innerHTML = "";
setQuarantineBadge(list.length);
$("qt-count").textContent = list.length
? `${list.length} item${list.length > 1 ? "s" : ""} held`
: "vault is empty";
if (!list.length) {
body.innerHTML = '<p class="muted empty">Nothing in quarantine. Your clean system thanks you.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "qt-row" + (e.restored ? " restored" : "");
row.innerHTML =
`<span class="qt-virus">${escapeHtml(e.virus)}</span>
<span>
<span class="qt-path" title="${escapeHtml(e.original_path)}">${escapeHtml(e.original_path)}</span><br/>
<span class="qt-meta">${fmtSize(e.size)} · quarantined ${fmtTime(e.ts)}${e.restored ? " · restored" : ""}</span>
</span>
<span class="qt-actions"></span>`;
const actions = row.querySelector(".qt-actions");
if (!e.restored) {
const rb = document.createElement("button");
rb.className = "btn small";
rb.textContent = "Restore";
rb.onclick = async () => {
rb.disabled = true;
try { await invoke("quarantine_restore", { id: e.id }); loadQuarantine(); }
catch (err) { rb.disabled = false; alert("Restore failed: " + err); }
};
actions.appendChild(rb);
}
const xb = document.createElement("button");
xb.className = "btn small danger";
xb.textContent = "Remove";
xb.onclick = async () => {
if (!confirm(`Remove ${e.virus} from the vault? The bytes are deleted for good.`)) return;
xb.disabled = true;
try { await invoke("quarantine_remove", { id: e.id }); loadQuarantine(); }
catch (err) { xb.disabled = false; alert("Remove failed: " + err); }
};
actions.appendChild(xb);
body.appendChild(row);
}
} catch (e) {
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(explain(e))}</p>`;
}
}
// ── Realtime tab ───────────────────────────────────────────────────
function fmtUptime(secs) {
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), s = secs % 60;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
async function loadRealtime() {
try {
const rt = await invoke("realtime_status");
const st = await invoke("settings");
$("rt-toggle").checked = rt.enabled;
$("rt-enabled-label").textContent = rt.enabled ? "monitor running" : "monitor off";
$("rt-seen").textContent = rt.files_seen.toLocaleString();
$("rt-quar").textContent = rt.files_quarantined.toLocaleString();
$("rt-uptime").textContent = rt.active ? fmtUptime(rt.uptime_secs) : "—";
const rw = $("rt-ransom");
rw.textContent = rt.ransomware;
rw.className = "stat-val " +
(rt.ransomware === "alarm" ? "alarm" : rt.ransomware === "watching" ? "watching" : "calm");
$("rt-watch").textContent = rt.watching.join(", ") || "—";
$("rt-last").textContent = rt.last_event_at ? fmtTime(rt.last_event_at) : "none yet";
$("rt-action").textContent = st.on_detect;
const wl = $("rt-watch-list");
wl.innerHTML = "";
for (const dir of st.realtime_watch) {
const chip = document.createElement("span");
chip.className = "watch-chip";
chip.textContent = dir;
wl.appendChild(chip);
}
} catch (e) {
$("rt-enabled-label").textContent = "error: " + explain(e);
}
}
// ── Rootkit tab ────────────────────────────────────────────────────
async function runRootkit() {
const btn = $("btn-rootkit");
btn.disabled = true;
btn.textContent = "Scanning…";
$("rootkit-banner").classList.add("hidden");
$("rootkit-body").innerHTML = '<p class="muted empty">Checking setuid bits, deleted executables, world-writable binaries…</p>';
try {
const r = await invoke("rootkit_scan");
const banner = $("rootkit-banner");
const dirty = r.critical + r.warn > 0;
banner.textContent = dirty ? `${r.verdict}` : `${r.verdict}${r.info} informational note${r.info === 1 ? "" : "s"}`;
banner.className = "verdict " + (dirty ? "dirty" : "clean");
banner.classList.remove("hidden");
const body = $("rootkit-body");
body.innerHTML = "";
if (!r.findings.length) {
body.innerHTML = '<p class="muted empty">No findings at all — squeaky clean.</p>';
return;
}
const rank = { critical: 0, warn: 1, info: 2 };
const sorted = [...r.findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3));
for (const f of sorted) {
const row = document.createElement("div");
row.className = "finding-row";
row.innerHTML =
`<span class="sev ${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span>
<span class="check">${escapeHtml(f.check)}</span>
<span class="detail">${escapeHtml(f.detail)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(explain(e))}</p>`;
} finally {
btn.disabled = false;
btn.textContent = "Run Scan";
}
}
// ── Alerts tab ─────────────────────────────────────────────────────
function setAlertsBadge(n) {
const b = $("badge-alerts");
b.classList.toggle("hidden", n === 0);
b.textContent = n;
}
async function loadAlerts() {
try {
const list = await invoke("events", { limit: 200 });
$("alerts-count").textContent = list.length ? `${list.length} events` : "";
setAlertsBadge(list.filter((e) => e.severity === "critical").length);
const body = $("alerts-body");
body.innerHTML = "";
if (!list.length) {
body.innerHTML = '<p class="muted empty">No events yet — the dog hasnt barked.</p>';
return;
}
for (const e of list) {
const row = document.createElement("div");
row.className = "event-row";
row.innerHTML =
`<span class="ev-sev ${escapeHtml(e.severity)}">${escapeHtml(e.severity)}</span>
<span class="ev-kind">${escapeHtml(e.kind)}</span>
<span class="ev-msg">${escapeHtml(e.message)}</span>
<span class="ev-ts">${fmtTime(e.ts)}</span>`;
body.appendChild(row);
}
} catch (e) {
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(explain(e))}</p>`;
}
}
// ── Settings tab ───────────────────────────────────────────────────
let settingsCache = null;
async function loadSettings() {
try {
const s = await invoke("settings");
settingsCache = s;
$("set-paused").checked = s.paused;
$("set-autoupdate").checked = s.auto_update_signatures;
$("set-notify").checked = s.notify_desktop;
$("set-realtime").checked = s.realtime_enabled;
$("set-watch").value = (s.realtime_watch || []).join("\n");
$("set-ondetect").value = s.on_detect;
$("set-maxsize").value = s.max_file_size_mb;
$("set-excludes").value = (s.exclude_paths || []).join("\n");
$("set-recursive").checked = s.recursive_default;
$("set-ransom").checked = s.ransomware_guard;
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
$("set-rootkit").checked = s.rootkit_enabled;
} catch (e) {
$("settings-msg").textContent = "Failed to load: " + explain(e);
}
}
async function saveSettings() {
if (!settingsCache) return;
const s = settingsCache;
s.paused = $("set-paused").checked;
s.auto_update_signatures = $("set-autoupdate").checked;
s.notify_desktop = $("set-notify").checked;
s.realtime_enabled = $("set-realtime").checked;
s.realtime_watch = $("set-watch").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.on_detect = $("set-ondetect").value;
s.max_file_size_mb = Math.max(1, parseInt($("set-maxsize").value, 10) || 0);
s.exclude_paths = $("set-excludes").value.split("\n").map((x) => x.trim()).filter(Boolean);
s.recursive_default = $("set-recursive").checked;
s.ransomware_guard = $("set-ransom").checked;
s.ransomware_threshold_per_min = Math.max(10, parseInt($("set-ransom-thresh").value, 10) || 100);
s.rootkit_enabled = $("set-rootkit").checked;
try {
const saved = await invoke("set_settings", { s });
paused = saved.paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
$("settings-msg").textContent = "Saved ✓";
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
loadRealtime().catch(() => {});
} catch (e) {
$("settings-msg").textContent = explain(e);
}
}
// ── Wiring ─────────────────────────────────────────────────────────
function setButtons(disabled) {
for (const id of ["btn-scan-home", "btn-scan-custom", "btn-update", "btn-pause"])
$(id).disabled = disabled;
}
function escapeHtml(s) {
return String(s).replace(/[&<>\"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
async function pickFolder() {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const dir = await open({ directory: true, multiple: false, title: "Choose a folder to scan" });
if (dir) doScan(dir);
} catch {
const p = prompt("Folder to scan:");
if (p) doScan(p);
}
}
function togglePause() {
paused = !paused;
$("btn-pause").textContent = paused ? "Resume Protection" : "Pause Protection";
setState(paused ? "paused" : "protected");
}
$("btn-scan-home").addEventListener("click", () => doScan("~"));
$("btn-scan-custom").addEventListener("click", pickFolder);
$("btn-update").addEventListener("click", doUpdate);
$("btn-pause").addEventListener("click", togglePause);
$("btn-qt-refresh").addEventListener("click", () => loadQuarantine());
$("btn-alerts-refresh").addEventListener("click", () => loadAlerts());
$("btn-rootkit").addEventListener("click", runRootkit);
$("btn-settings-save").addEventListener("click", saveSettings);
$("btn-qt-add").addEventListener("click", async () => {
const path = $("qt-add-path").value.trim();
const virus = $("qt-add-virus").value.trim() || "manual";
if (!path) return alert("Enter a file path to quarantine.");
try {
await invoke("quarantine_add", { path, virus });
$("qt-add-path").value = "";
loadQuarantine();
} catch (e) { alert("Quarantine failed: " + e); }
});
$("btn-alerts-clear").addEventListener("click", async () => {
if (!confirm("Clear the whole event log?")) return;
try {
const n = await invoke("clear_events");
$("alerts-count").textContent = `${n} event${n === 1 ? "" : "s"} cleared`;
loadAlerts();
} catch (e) { alert("Clear failed: " + e); }
});
$("rt-toggle").addEventListener("change", async (ev) => {
try {
await invoke("realtime_set_enabled", { enabled: ev.target.checked });
loadRealtime();
} catch (e) {
ev.target.checked = !ev.target.checked;
alert("Toggle failed: " + e);
}
});
// Tray menu events (Scan Home / Scan Downloads / Update).
listen("tray-event", (e) => {
const p = e.payload;
if (p?.action === "scan" && p.path) {
switchTab("protection");
doScan(p.path);
} else if (p?.action === "update") {
switchTab("protection");
doUpdate();
}
});
// Initial boot.
async function boot() {
try {
const st = await invoke("status");
renderStatus(st);
if (st.engine_present) setState(paused ? "paused" : "protected");
else setState("paused");
} catch {
setEngineDot("bad", "engine offline");
setState("paused");
$("hero-sub").textContent =
"Can't reach houndd. Check that the service is up (`systemctl status houndd`). " +
"If it is, you may not be in the `hound` group yet — the installer adds you, " +
"but it only takes effect after you log out and back in.";
}
}
// Tells the watchdog in index.html that the script actually ran.
window.__houndBooted = true;
boot();