Antivirus/packaging/build-deb.sh
dev aae41a9371 0.1.6: security hygiene, and right-click scanning
Malware scanning asks whether a file is hostile. The check that
actually loses people their accounts is a different one: what has
already been exposed, and what is about to be? crates/hound-supply/src/
hygiene.rs answers it, and runs as part of every project sweep.

  - A secret file tracked by git. The emergency case: it is in the
    history, in every clone, and in every fork. The advice says rotate
    BEFORE `git rm --cached`, because removing a pushed secret does not
    un-share it, and a test asserts that ordering.
  - Credentials hardcoded in source, recognised by issuer format —
    AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, GitLab, npm, PyPI,
    Google, and the PEM private-key headers.
  - Secret files readable by every account on the machine.
  - Secret files with nothing in .gitignore covering them: the near
    miss that the next `git add -A` turns into the emergency above.
  - GitHub Actions: pull_request_target with a checkout of the pull
    request (a stranger's code, your secrets, your write token), a
    secret echoed into the build log, a downloaded script piped into a
    shell, and third-party actions on a moving tag.

Two rules govern all of it. **Findings are actionable**: no entropy
heuristics, because "high entropy string" is a coin flip a human then
has to adjudicate, and people stop reading after the second false
alarm. Every detector recognises a documented credential format or
reports a structural fact that is true or false. **Nothing secret is
copied into a finding** — a report naming the key it found has moved
the key into a log, a CI artefact, or an assistant's context window,
which is the thing being prevented. There is a test for that.

Tracked-file status comes from parsing .git/index rather than running
git: the sweep is pointed at repositories precisely because they are
not trusted, and starting a subprocess inside one is what a hostile
repository wants.

Against a deliberately bad test repository: four critical, five
warnings, and correctly silent on .env.example and actions/checkout@v4
— flagging those is how a scanner gets ignored.

Also in this release:

  - Right-click "Scan for Threats with Hound" in Nemo, Caja and
    Dolphin, whose menu entries are system files. GNOME Files and
    Thunar keep theirs per-user, so `hound context-menu install`
    handles those. The icon is symbolic, so the file manager recolours
    it to the menu's own theme instead of dropping a violet dog into a
    monochrome menu.
  - A right-click while the app is already open hands the request to
    the running instance rather than refusing. A menu item that
    silently does nothing because the app happens to be open is
    indefensible.
  - Two fixes for the duplicate tray icon. The updater slept a fixed
    600ms after SIGTERM and then started the replacement; if the old
    process outlived that, the panel kept its item and the result was
    two dogs, the older of which could not be clicked or closed because
    nothing was behind it. It now waits for the process to actually
    leave /proc, escalating to SIGKILL after five seconds. And the app
    itself now holds an advisory lock for its lifetime, so a second
    instance cannot exist — the kernel releases the lock however the
    process dies, so a stale one is not a state that can happen.

402 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:15:35 -05:00

295 lines
11 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"
# Right-click "Scan for Threats with Hound" in the file managers whose menu
# entries are system-wide files. GNOME and Thunar keep theirs per-user, so
# those are installed by `hound context-menu install` instead.
# A symbolic icon, so the file manager recolours it to whatever the menu
# theme is rather than dropping a violet dog into a monochrome menu. GTK does
# the recolouring itself when the icon is named -symbolic and lives here.
install -Dm644 "$ROOT/assets/icons/hound-symbolic.svg" \
"$STAGE/usr/share/icons/hicolor/symbolic/apps/hound-symbolic.svg"
install -Dm644 "$ROOT/packaging/filemanager/hound-scan.nemo_action" \
"$STAGE/usr/share/nemo/actions/hound-scan.nemo_action"
install -Dm644 "$ROOT/packaging/filemanager/hound-scan.caja_action" \
"$STAGE/usr/share/caja/actions/hound-scan.caja_action"
install -Dm644 "$ROOT/packaging/filemanager/hound-scan.desktop" \
"$STAGE/usr/share/kio/servicemenus/hound-scan.desktop"
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/^/ /'