Daily-delta security and health audit for personal Linux boxes, designed for at-a-glance delivery to a phone via Telegram / Discord / Pushover.
box-audit runs ~25 checks against a Linux box and emits a short report
flagging anything that differs from a normal baseline. It complements — does
not replace — tools like Lynis by
answering a different question:
- Lynis: "What's the absolute state of my hardening?" (score 0–100, hundreds of CIS-style checks, run weekly/monthly)
- box-audit: "What's different since yesterday?" (delta-style, finds new SUID binaries, unexpected outbound connections, custom cron jobs, pending reboots, security updates — run daily)
| Section | Checks |
|---|---|
| Resources | Disk > 85%, swap > 70%, load > 3.0 |
| Security | fail2ban banned IPs (per jail — active jails discovered at runtime via fail2ban-client status; the sshd floor is the pinned security.fail2ban_banned), SSH failures (24h), sudo failures, listening ports vs baseline, outbound non-LAN IPs (catches C2 — IPv4+IPv6 combined as security.outbound_remote_count, the IPv6 slice as its own security.outbound_remote_count_v6), SUID binary count (catches rootkits) |
| System | Failed systemd units, unhealthy Docker containers, Apport crash dumps, kernel errors, non-standard systemd timers (catches persistence), unexpected user crontabs, unexpected /etc/cron.d/ drop-ins |
| Updates | Pending security updates, kernel CVEs (reboot-required), origin classification (distro vs third-party) |
| Maintenance | Reboot-required state, apt cache freshness, unattended-upgrades health, needrestart (libc/kernel drift, services needing restart) |
The bold items are the delta-style checks that distinguish box-audit from Lynis-style absolute scoring.
# Human-readable (default): emoji-coded lines for Telegram / Discord
./scripts/box-audit.sh
# Machine-readable JSON for webhooks / piped delivery
./scripts/box-audit.sh --json--json output shape:
{
"status": "findings",
"timestamp": "2026-09-15T14:29:33Z",
"host": "your-hostname",
"counts": {
"suid_count": 9,
"outbound_remote_count": 3,
"security_pending": 2
},
"findings": [
{"severity": "warn", "check_id": "updates.security_pending", "message": "2 security update(s) pending", "count": 2},
{"severity": "warn", "check_id": "maintenance.kernel_restart", "message": "Kernel: 7.0.0-31-generic (newer kernel on disk, current kernel still running)"}
],
"raw_output": "..."
}The counts block carries the live suid_count / outbound_remote_count /
security_pending measurements regardless of whether the corresponding
threshold tripped, and is the source of truth for tomorrow's delta mode
(see issue #38).
Added in 0.7.1.
Each finding carries a stable check_id (see box-audit --print-schema
for the full table), so downstream tools can branch without parsing the
message text. Count-carrying findings (security.outbound_remote_count,
security.suid_count, updates.security_pending) also carry a numeric
count field mirroring the value in the top-level counts block.
Exit codes: 0 = all clear, 1 = findings present, 2 = bad CLI flag,
75 = lock file unopenable (read-only lock dir) — the run was refused
before any check ran. In --json mode the exit code is 0 on a run that
executes; the JSON body's status field (ok vs findings) is the
signal instead. A refused run exits 75 with no JSON on stdout.
git clone https://github.com/ManningWorks/box-audit && cd box-audit
sudo ./install.shSame command for fresh installs and upgrades. It sanity-checks the script
before installing, writes the systemd units below, and finishes on a
verify gate (service success, latest.json parses, timer active with a
real next-run time) — non-zero exit on any failure. Re-runs only touch
files that changed, and a customized timer schedule is preserved with a
warning, never clobbered.
Dependencies: sudo apt install -y needrestart fail2ban python3
(docker only if you run containers and want the health check). Install
them before or after — the audit degrades those checks gracefully and
names what's missing.
sudo systemctl disable --now box-audit.timer
sudo rm /etc/systemd/system/box-audit.service /etc/systemd/system/box-audit.timer
sudo rm -f /usr/local/bin/box-audit /usr/local/bin/notify-webhook.sh
sudo rm -rf /usr/local/share/box-audit /var/lib/box-audit /var/log/box-audit
sudo systemctl daemon-reload/var/lib/box-audit/ holds the per-box allowlists and the file-integrity
baseline; /var/log/box-audit/ holds the snapshots and delta history.
Deleting them resets everything the tool has learned about your box —
the next run re-seeds from scratch.
# 1. Install dependencies (Ubuntu/Debian)
sudo apt install -y needrestart fail2ban python3
# docker only if you run containers and want the health check:
# sudo apt install -y docker.io
# 2. Drop the script somewhere on PATH
sudo install -m 0755 scripts/box-audit.sh /usr/local/bin/box-audit
# 3. Test it
sudo box-auditRun it with sudo at least once (or via the systemd unit, which runs as root) so the file-integrity baseline can read all crown-jewel files. Non-root runs skip the integrity check rather than poison the baseline.
If you use an AI agent that supports the Skills format
(Hermes, opencode, Claude Code, etc.), point it at the skills/box-audit/SKILL.md file:
"Install the box-audit skill from https://github.com/ManningWorks/box-audit/tree/master/skills/box-audit"
The agent runs the same install.sh you'd run by hand, walks the verify
gate, reads the findings, and reports back — the skill encodes how to
interpret and triage the output, not a separate install path.
install.sh writes these units for you; shown here for the manual path or
if you want to know what lands on your box:
The unit runs box-audit --json once a day. To inspect or tweak the
allowlists (--init, --accept-port, --accept-timer,
--outbound-threshold), see
skills/box-audit/references/cli.md.
box-audit --init tells you what it did: config unchanged at /var/lib/box-audit when every allowlist already held exactly what a fresh
snapshot would write (a no-op re-run), or seeded <files> when it rewrote
one or more of them.
# /etc/systemd/system/box-audit.service
[Unit]
Description=box-audit daily security + health check
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=root
# DO NOT use `ExecStart=/bin/bash -c '... > /path'` — systemd parses
# whitespace as argv boundaries and will pass the redirect target to
# bash as a positional argument instead of shell syntax. Use
# StandardOutput=truncate:... to bypass the shell entirely.
StandardOutput=truncate:/var/log/box-audit/latest.json
StandardError=journal
ExecStart=/usr/local/bin/box-audit --json
# Sudo is invoked internally by the script for fail2ban/docker checks;
# run as root or grant NOPASSWD to /usr/bin/fail2ban-client, /usr/bin/docker.
[Install]
WantedBy=multi-user.target# /etc/systemd/system/box-audit.timer
[Unit]
Description=Run box-audit daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now box-audit.timerEvery run leaves the full report in /var/log/box-audit/latest.json. You
— or your agent — read it when you ask "how's the box?". Nothing arrives
unprompted.
That's not a missing feature. A daily audit that pings "all clear!" every
morning trains you to ignore it within a week, and then the one morning it
says something real, you will too. Silence means nothing changed. When
something does, the report is already on disk, waiting to be read — and
the severity table in skills/box-audit/SKILL.md says how to read it.
If you'd rather certain findings come to you, ship them with
scripts/notify-webhook.sh: it reads latest.json and POSTs the JSON
body to $BOX_AUDIT_WEBHOOK_URL (set it in the environment or in
/etc/default/box-audit). Any incoming-webhook endpoint works — Discord
webhook, a Telegram bot via a relay, ntfy, your own receiver.
Even on a healthy box the snapshot carries a few routine info-level
findings, so the push lands almost daily. If you'd rather be woken only
for real signal, set BOX_AUDIT_NOTIFY_MIN_SEVERITY in the same
environment (or /etc/default/box-audit) to warn or crit: findings
below the threshold stay on the box — latest.json and the pull path
are untouched — and the notifier logs a suppressed N findings below <threshold> threshold count to stderr. warn keeps warn and above;
crit keeps only the top severity. degraded findings are never
suppressed: they say a check couldn't run, and hiding that in the push
is worse than a noisy push. The default is unchanged — with the variable
unset, every finding is posted, exactly as before.
Wire it into the daily run with a drop-in, not a second unit:
sudo systemctl edit box-audit.service[Service]
ExecStartPost=/usr/local/bin/notify-webhook.shThen sudo systemctl daemon-reload. install.sh puts the notifier at
/usr/local/bin/notify-webhook.sh alongside the main script.
ExecStartPost runs after the main process exits, so it pushes this
run's fresh snapshot and latest.json stays intact for pull readers.
Don't replace ExecStart with a pipe into the notifier — the pipe eats
the run's output, the notifier doesn't read stdin, and the unit's
truncate: capture would then overwrite latest.json with nothing.
When the notifier is wired in, the verify gate still checks the file,
plus the webhook POST now participates in the unit's success/failure.
latest.json is overwritten in place on every run — it never grows.
The daily snapshots in /var/log/box-audit/history/ are bounded by the
script itself: each run deletes snapshots older than 30 days after
writing today's. There is deliberately no logrotate config — rotating
(rename + compress) the date-named snapshots would break --diff,
which looks them up by exact filename. If you want a longer history,
raise HISTORY_RETENTION_DAYS in the script; if you want the space
back sooner, delete old files from history/ — the tool re-seeds
gracefully.
What the author actually runs: a Hermes cron job once a day whose entire configuration is this prompt — the agent does the reading and the sending, the box-audit timer does the auditing:
Read
/var/log/box-audit/latest.json.If the read SUCCEEDS: parse the JSON. If
status == 'ok', send '✅ Box audit clean (timestamp )'. Ifstatus == 'findings', send each entry fromfindings[]as one Telegram line usinghermes-telegram-send. Group findings with the same severity together. Include the timestamp from the JSON.If the read FAILS with Permission denied (EACCES):
- Read the
Groups:line of/proc/self/status. Split its value on whitespace and compare WHOLE TOKENS (never substring — gid 43 must not match a list containing 4) against the boxaudit gid. Find the numeric gid with:getent group boxaudit(third colon-separated field).- If the gid is ABSENT from your group list, send: '
⚠️ box-audit: stale process group membership — this agent started before the boxaudit group was added. Restart the consumer; if it runs under systemd --user, run systemctl --user daemon-reexec first, or log out and back in.' Do NOT say 'check the systemd timer' — the timer is not the problem.- If the gid IS present, send: '
⚠️ box-audit: latest.json exists but is unreadable (Permission denied) — permissions have drifted from the documented 0640 root:boxaudit layout. Inspect the file's mode and owner.' Again, do not blame the timer.If the file is MISSING, EMPTY, or UNPARSEABLE (no Permission denied): send a single Telegram message: '❌ box-audit: latest.json missing or unreadable — check the systemd timer'.
Do NOT run the script yourself — only read the JSON file written by the box-audit systemd timer. (Diagnostic escape hatch, user request only:
box-audit --check-groupsreports stale group membership for this user's own processes without fixing anything.)
No separate delivery daemon to keep alive. Severity → Telegram format:
see the severity table in skills/box-audit/SKILL.md § 2, which is
also the contract the cron should follow.
- Tested on: Ubuntu 24.04 LTS (Noble)
- Should work on: any Debian/Ubuntu LTS, recent Fedora (untested)
- Won't work on: macOS, Windows, Alpine (uses systemd, apt, journalctl, fail2ban-client — Ubuntu/Debian idioms)
Three tiers of tests, one per class of regression — plus a single local entry point that runs them in sequence.
Single local entry point: bash test/all.sh runs all three tiers in
sequence. Tier 3 skips itself on non-privileged hosts; the summary
line reads all: 4 passed or all: 3 passed, 1 skipped.
test/smoke.sh — runs on a bare non-root runner (no container, no
install). Covers the audit's degraded-path surface (empty history,
unparseable JSON, missing binaries) and shellcheck across the shipped
shell surface. Also runs test/properties/run.sh with a 5-second
budget. Cheap; runs on every push and PR. Required check on every PR
via .github/workflows/ci.yml.
install.sh --ci inside a privileged jrei/systemd-ubuntu:26.04
container plus a negative variant that mutates ExecStart= to confirm
the verify gate fails loudly. Exercises the full install path on a
real systemd. Required check on every PR via
.github/workflows/install.yml.
.github/workflows/integration-seeded.yml (issue #17) runs
test/install-seeded.sh, which boots a seeded container that
produces a known mix of findings, then asserts via
test/install-seeded/assert-json.py that the expected eight
check_ids appear with the expected severities. Negative variant
sed-mutates one seeded condition in a throwaway build context and
inverts the resulting driver failure into a pass — the proof that
the gate has teeth. Required check on every PR. Catches regressions
the other two tiers cannot, because ephemeral runners have no
filesystem state to exercise.
bash test/local-integration.sh — the author's local pre-merge net.
Mirrors tier 1 (privileged systemd container, install, audit) but
asserts the JSON shape and the +replay version-suffix invariant on
the installed binary — not per-check severities (that's tier 2).
Tier 3's probe pins the four core top-level keys (status, timestamp,
host, findings) — counts and raw_output are intentionally not
re-pinned here; counts is asserted by tier 2's assert-json.py on the
installed binary (same surface), and raw_output is a verbatim copy
of the text report rather than a contract field. Hard budget: 240
seconds (raised from 60s for issue #31 — the stale-group phase runs
three more full installs inside the same container). Skips itself with
skipped: requires privileged Docker and
exits 0 when the host can't grant --privileged. Documented step, not
a GitHub Actions gate: the integration-seeded status check on the PR
is what catches regressions for external contributors; tier 3 is the
author's pre-merge net.
Dependabot bumps the base image weekly.
- Not a security tool. It is an observer. It does not block, patch, quarantine, or remediate. It surfaces things; you decide.
- Not comprehensive. It checks ~25 things. Lynis checks hundreds.
- Not CIS-compliant. No compliance framework. If you need CIS / PCI / HIPAA evidence, run Lynis or OpenSCAP.
Lynis is excellent. Use it for monthly deep audits. The reason box-audit exists alongside it:
| box-audit | Lynis | |
|---|---|---|
| Frequency | Daily | Weekly/monthly |
| Output shape | One Telegram screen | Hundreds of lines |
| Style | Delta (what changed) | Score (absolute) |
| Runtime | ~2 seconds | 1–5 minutes |
| Dependencies | bash + coreutils | None (Perl-like bundle) |
| Learning curve | Zero (read the README) | Medium (test IDs, profiles) |
Run both. Lynis monthly for the score; box-audit daily for the delta.
MIT. See LICENSE.
Luke Manning — lukemanning.ie