A clean laptop reported 988 critical rootkit findings; this server
3786, PID 1 among them. Every one was false, and the cause was our own
systemd hardening.
ProtectProc=invisible hides processes the daemon does not own from its
view of /proc, while kill(pid, 0) keeps answering truthfully because it
is a syscall and not a filesystem lookup. The hidden-process check
compares exactly those two sources, so with that setting every process
on the machine looked concealed. Enumerating processes is this daemon's
job, so it needs the default view.
Removing the setting is not enough on its own — hidepid= on the /proc
mount produces the same blindness and we do not control that. So the
detector now recognises when it cannot see:
- PID 1 is the control. It always exists and nothing hides init; a
rootkit that did would break the machine it is living on. If PID 1
answers kill(1, 0) but is absent from the listing, we are blind and
say so as info rather than crying rootkit.
- A plausibility ceiling of 32. Hiding a handful of processes is the
entire point of a rootkit; hundreds means a broken observer. An
antivirus that reports a critical rootkit finding on every clean
machine teaches people to ignore the one time it is real.
Also in this change, from testing on a real desktop:
- Closing the window hides it to the tray instead of exiting, with a
one-time notification so it does not read as a crash. Quit lives
only in the tray menu and confirms first. The settings already had
close_to_tray and confirm_quit fields wired to nothing; they are
honoured now rather than hardcoded.
- The tray menu and Scan Home sent the literal string "~". A shell
would have expanded it, nothing here did, so the daemon was asked
to scan a directory of that name. It failed silently until the
per-peer readability check made it audible.
- Administrative actions elevate through polkit instead of telling
people to open a terminal. The app tries unprivileged first and
only on a privilege refusal runs `pkexec hound admin-rpc`, which
forwards one request as root. auth_admin_keep, because prompting on
every settings toggle trains people to authenticate without reading
the prompt. This grants what `sudo hound` already grants to people
who could already run sudo — a transport, not a new privilege.
- `hound settings exec-gate on|off` now exists. The install script,
the AppImage banner, the rpm spec, the AUR install file and
llms.txt all told users to run `hound settings set exec_gate true`.
There was no `set` subcommand and no way to enable the execution
gate from the CLI at all: the flagship paid feature was unreachable
and the first thing a new user was told to type returned an error.
A test now asserts every documented command parses.
- `settings show` displays the exec gate state, and no longer prints
its own header twice.
- The CLI help still described ClamAV, which has not been the engine
for some time. So did the socket permission error, which now
explains the `hound` group and the log-out-and-back-in it needs.
368 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
10 KiB
Bash
Executable file
279 lines
10 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# Build a .deb for Ubuntu / Debian / Linux Mint.
|
|
#
|
|
# Deliberately hand-rolled rather than cargo-deb: the package needs a
|
|
# postinst that creates the vault with the right mode, a conffile that
|
|
# survives upgrades, and a unit that is enabled but whose gate stays off
|
|
# until the operator turns it on. That is easier to read as a script than
|
|
# as a pile of metadata, and it is the thing most likely to need auditing.
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)"
|
|
ARCH="$(dpkg --print-architecture)"
|
|
OUT="${OUT:-$ROOT/dist}"
|
|
STAGE="$(mktemp -d)"
|
|
trap 'rm -rf "$STAGE"' EXIT
|
|
# mktemp -d creates 0700, and dpkg applies the staging root's mode to "/".
|
|
# Installing this package would chmod / to 0700 and break the machine.
|
|
chmod 0755 "$STAGE"
|
|
|
|
echo "building hound ${VERSION} (${ARCH})"
|
|
( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp )
|
|
|
|
# The desktop app. Optional: a build host without the webkit/gtk
|
|
# development libraries still produces a working CLI package, it just
|
|
# does not ship a launcher — which is better than shipping a menu entry
|
|
# for a binary that is not there.
|
|
GUI_BIN="$ROOT/gui/src-tauri/target/release/hound-gui"
|
|
if command -v npx >/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
|
|
# NOT silenced, and NOT tolerant of failure. Discarding this output once
|
|
# meant a config error scrolled past unseen and the package shipped the
|
|
# previous build's binary — the fix looked like it had no effect, twice.
|
|
( cd "$ROOT/gui" && npm install --no-audit --no-fund >/dev/null \
|
|
&& npx tauri build --no-bundle )
|
|
fi
|
|
# The webview loads dist/*.js directly, with no bundler. A bare module
|
|
# specifier there does not error loudly — it silently fails to resolve and the
|
|
# window renders its static HTML forever. Catch it here instead of in a bug
|
|
# report.
|
|
if grep -rnE '^\s*import .* from "[^./]' "$ROOT/gui/dist"/*.js 2>/dev/null; then
|
|
echo "ERROR: bare module specifier in the front-end; the webview cannot resolve it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ -x "$GUI_BIN" ]; then
|
|
HAVE_GUI=yes
|
|
echo " including the desktop app"
|
|
else
|
|
HAVE_GUI=no
|
|
echo " NOTE: no GUI binary — packaging the CLI only, and no launcher"
|
|
fi
|
|
|
|
install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd"
|
|
install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound"
|
|
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
|
|
[ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui"
|
|
|
|
# Lets the desktop app elevate a single daemon request through polkit rather
|
|
# than asking people to open a terminal for every settings change.
|
|
install -Dm644 "$ROOT/packaging/polkit/com.houndav.hound.policy" \
|
|
"$STAGE/usr/share/polkit-1/actions/com.houndav.hound.policy"
|
|
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
|
|
"$STAGE/lib/systemd/system/houndd.service"
|
|
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
|
|
"$STAGE/usr/share/hound/rules/hound-builtin.yar"
|
|
install -Dm644 "$ROOT/README.md" "$STAGE/usr/share/doc/hound/README.md"
|
|
|
|
# Launcher icon: the white mark on a periwinkle tile (app-*.png), not the
|
|
# bare brand mark. The tray ladder is a different family and ships with
|
|
# the GUI, because tray glyphs must stay transparent to sit on any panel.
|
|
for size in 16 22 24 32 48 64 128 256 512; do
|
|
src="$ROOT/assets/icons/app-${size}.png"
|
|
[ -f "$src" ] && install -Dm644 "$src" \
|
|
"$STAGE/usr/share/icons/hicolor/${size}x${size}/apps/hound.png"
|
|
done
|
|
install -Dm644 "$ROOT/assets/icons/hound-app.svg" \
|
|
"$STAGE/usr/share/icons/hicolor/scalable/apps/hound.svg"
|
|
|
|
# A menu entry is a promise that clicking it opens something. It ships
|
|
# only when the desktop app does, and it launches THAT rather than the
|
|
# CLI — Exec=hound with Terminal=true opened a terminal, printed help and
|
|
# exited, which reads to anyone sane as "it does not launch".
|
|
if [ "$HAVE_GUI" = yes ]; then
|
|
install -Dm644 /dev/stdin "$STAGE/usr/share/applications/hound.desktop" <<'DESKTOP'
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=Hound Antivirus
|
|
GenericName=Antivirus
|
|
Comment=Endpoint and supply-chain protection for Linux
|
|
Exec=hound-gui
|
|
Icon=hound
|
|
Categories=System;Security;
|
|
Keywords=antivirus;malware;security;scan;supply chain;
|
|
Terminal=false
|
|
StartupWMClass=hound-gui
|
|
StartupNotify=true
|
|
DESKTOP
|
|
fi
|
|
|
|
mkdir -p "$STAGE/DEBIAN"
|
|
|
|
GUI_DEPENDS=""
|
|
[ "$HAVE_GUI" = yes ] && GUI_DEPENDS=", libwebkit2gtk-4.1-0, libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1"
|
|
|
|
cat > "$STAGE/DEBIAN/control" <<CONTROL
|
|
Package: hound
|
|
Version: ${VERSION}
|
|
Section: utils
|
|
Priority: optional
|
|
Architecture: ${ARCH}
|
|
Maintainer: Hound <support@houndav.com>
|
|
Depends: libc6 (>= 2.34)${GUI_DEPENDS}
|
|
Suggests: clamav-daemon
|
|
Homepage: https://houndav.com
|
|
Description: Hound Antivirus for Linux
|
|
Endpoint and supply-chain protection built for the distributions people
|
|
actually run. Scanning is yara-x in process; real-time protection uses
|
|
fanotify, so a binary can be refused at execve rather than reported
|
|
after it has already run.
|
|
.
|
|
The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and
|
|
covers the whole root filesystem, so turning it on is the operator's
|
|
decision: hound settings exec-gate on
|
|
CONTROL
|
|
|
|
cat > "$STAGE/DEBIAN/conffiles" <<'CONFFILES'
|
|
/etc/hound/hound.toml
|
|
CONFFILES
|
|
|
|
install -Dm644 /dev/stdin "$STAGE/etc/hound/hound.toml" <<'CONF'
|
|
# Hound Antivirus configuration.
|
|
#
|
|
# Live settings are managed through `hound settings` and stored per user;
|
|
# this file holds the machine-wide defaults the daemon starts from.
|
|
|
|
# Deny execution until a verdict is returned. Needs CAP_SYS_ADMIN.
|
|
# Off by default: it covers the whole root filesystem, and that is the
|
|
# operator's call to make rather than the installer's.
|
|
exec_gate = false
|
|
|
|
# Mounts the gate covers. Empty means the root filesystem.
|
|
exec_gate_paths = []
|
|
|
|
# Never held for a verdict.
|
|
exclude_paths = ["/proc", "/sys", "/dev", "/run", "/var/lib/docker"]
|
|
|
|
# Files larger than this are allowed through unread.
|
|
max_file_size_mb = 100
|
|
|
|
# "quarantine" or "alert".
|
|
on_detect = "quarantine"
|
|
CONF
|
|
|
|
cat > "$STAGE/DEBIAN/postinst" <<'POSTINST'
|
|
#!/bin/sh
|
|
set -e
|
|
|
|
case "$1" in
|
|
configure)
|
|
# The desktop app runs as the logged-in user; the daemon runs as root.
|
|
# `hound` is how they meet. Membership grants the read side of the API
|
|
# only — status, scan results, the event log — because quarantine writes
|
|
# files back out as root and that is not something a group should confer.
|
|
if ! getent group hound >/dev/null 2>&1; then
|
|
addgroup --system hound >/dev/null 2>&1 || groupadd -r hound >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# Enrol whoever ran the install, since on a desktop that is the person who
|
|
# will open the app. Group membership only takes effect on their next
|
|
# login, which is why the notice below says so out loud.
|
|
ADMIN="${SUDO_USER:-${PKEXEC_UID:-}}"
|
|
case "$ADMIN" in
|
|
''|root) ADMIN="" ;;
|
|
[0-9]*) ADMIN="$(getent passwd "$ADMIN" | cut -d: -f1)" ;;
|
|
esac
|
|
if [ -n "$ADMIN" ] && getent group hound >/dev/null 2>&1; then
|
|
if ! id -nG "$ADMIN" 2>/dev/null | tr ' ' '\n' | grep -qx hound; then
|
|
adduser "$ADMIN" hound >/dev/null 2>&1 || usermod -aG hound "$ADMIN" >/dev/null 2>&1 || true
|
|
ADDED_TO_GROUP=yes
|
|
fi
|
|
fi
|
|
|
|
# The vault holds live malware: root-only, and on a filesystem where
|
|
# nothing in it can be executed even by accident.
|
|
mkdir -p /var/lib/hound/vault /var/lib/hound/rules /var/log/hound
|
|
chmod 0700 /var/lib/hound/vault
|
|
chmod 0755 /var/lib/hound /var/lib/hound/rules
|
|
chmod 0750 /var/log/hound
|
|
|
|
# The built-in rules are compiled INTO the binary; /var/lib/hound/rules
|
|
# is for additional packs only. Copying the built-ins there made the
|
|
# daemon compile them twice and log a duplicate-declaration error on
|
|
# every start. The copy under /usr/share is documentation, not input.
|
|
|
|
# Menus cache icons; without this the entry can appear blank until the
|
|
# user logs out, which is indistinguishable from a broken package.
|
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
|
gtk-update-icon-cache -qtf /usr/share/icons/hicolor 2>/dev/null || true
|
|
fi
|
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
|
update-desktop-database -q /usr/share/applications 2>/dev/null || true
|
|
fi
|
|
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl daemon-reload || true
|
|
systemctl enable houndd.service || true
|
|
systemctl restart houndd.service || true
|
|
fi
|
|
|
|
echo ""
|
|
echo "Hound is installed and scanning on demand."
|
|
echo ""
|
|
if [ "${ADDED_TO_GROUP:-no}" = yes ]; then
|
|
echo "Added $ADMIN to the 'hound' group so the desktop app can talk to"
|
|
echo "the daemon. Log out and back in for that to take effect."
|
|
echo ""
|
|
fi
|
|
echo " hound status what the daemon sees"
|
|
echo " hound scan ~/Downloads scan a directory"
|
|
echo ""
|
|
echo "Real-time execution blocking is OFF until you turn it on:"
|
|
echo ""
|
|
echo " sudo hound settings exec-gate on"
|
|
echo ""
|
|
echo "To let a coding assistant check repositories before trusting them,"
|
|
echo "add this to its MCP configuration:"
|
|
echo ""
|
|
echo ' { "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }'
|
|
echo ""
|
|
;;
|
|
esac
|
|
exit 0
|
|
POSTINST
|
|
|
|
cat > "$STAGE/DEBIAN/prerm" <<'PRERM'
|
|
#!/bin/sh
|
|
set -e
|
|
case "$1" in
|
|
remove|deconfigure)
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl stop houndd.service || true
|
|
systemctl disable houndd.service || true
|
|
fi
|
|
;;
|
|
esac
|
|
exit 0
|
|
PRERM
|
|
|
|
cat > "$STAGE/DEBIAN/postrm" <<'POSTRM'
|
|
#!/bin/sh
|
|
set -e
|
|
case "$1" in
|
|
purge)
|
|
# The vault is deliberately NOT removed on `remove`, only on `purge`,
|
|
# and even then only after saying so: it may be the sole copy of
|
|
# evidence somebody still needs.
|
|
echo "Removing the Hound quarantine vault at /var/lib/hound/vault"
|
|
rm -rf /var/lib/hound /var/log/hound
|
|
# Leave the group behind if anyone is still in it — removing it would
|
|
# silently strip a gid that could be referenced elsewhere on the system.
|
|
if getent group hound >/dev/null 2>&1 && [ -z "$(getent group hound | cut -d: -f4)" ]; then
|
|
delgroup --system hound >/dev/null 2>&1 || groupdel hound >/dev/null 2>&1 || true
|
|
fi
|
|
;;
|
|
esac
|
|
if [ -d /run/systemd/system ]; then
|
|
systemctl daemon-reload || true
|
|
fi
|
|
exit 0
|
|
POSTRM
|
|
|
|
chmod 0755 "$STAGE/DEBIAN/postinst" "$STAGE/DEBIAN/prerm" "$STAGE/DEBIAN/postrm"
|
|
|
|
mkdir -p "$OUT"
|
|
DEB="$OUT/hound_${VERSION}_${ARCH}.deb"
|
|
fakeroot dpkg-deb --build --root-owner-group "$STAGE" "$DEB" >/dev/null
|
|
echo "built $DEB"
|
|
dpkg-deb -I "$DEB" | sed 's/^/ /'
|