A single-file Bash audit for Linux servers.
It looks at the running host and prints one line per check
with a PASS, WARN, FAIL, SKIP or INFO result.
There is no agent and no daemon to install, and nothing outside a stock Debian, Ubuntu or RHEL-family base system is required. The script never changes the host's configuration. It writes only what you point it at: the report, the baseline and the Prometheus file.
$ sudo ./postflight.sh
Checks
[PASS] SSH Root Login PermitRootLogin=no
[FAIL] Firewall no active firewall (ufw inactive, iptables policy ACCEPT, 0 rule(s))
[WARN] Listening Ports 3 public, 6 loopback-only (TCP+UDP). Public: tcp/22 sshd, tcp/80 nginx, udp/68 dhclient
[PASS] Security Updates no pending security updates
...
Summary
PASS 21 WARN 6 FAIL 2 SKIP 5 INFO 1
score 78/100
worst result: FAIL
Run ./postflight.sh --list to print the id of every check.
The groups below describe what those checks look at.
The effective configuration comes from sshd -T,
so Match blocks, Include directives and keyword casing
resolve the way sshd resolves them.
The checks cover root login, password authentication and the listening port.
Before reporting that either one is open,
the check looks for another account that could still get in:
a member of sudo, wheel or admin with a non-empty authorized_keys.
If there is none, the finding says so,
so you know that closing the door would lock you out with it.
KbdInteractiveAuthentication is checked alongside PasswordAuthentication,
because keyboard-interactive hands the prompt to PAM
and lets passwords through even when PasswordAuthentication no is set.
Every TCP and UDP socket from ss -H -tulnp,
split by bind address into public and loopback-only,
each with the process that owns it.
Ports published by Docker are listed on their own,
because Docker inserts them ahead of the UFW chain
and they stay reachable when UFW says the port is denied.
UFW, firewalld, nftables and iptables are all consulted, and the host counts as protected if any of them is doing the work. For iptables and nftables the verdict comes from the input chain policy and the number of rules.
Pending security updates are counted from the -security pocket only.
An apt cache too old to trust is reported as such rather than producing a pass.
unattended-upgrades has to be both installed and enabled,
which means APT::Periodic::Unattended-Upgrade and the timer,
not just a row in the package database.
Extra UID-0 users,
empty password fields in /etc/shadow,
authorized_keys for root,
members of the docker group,
NOPASSWD: ALL rules across /etc/sudoers and /etc/sudoers.d,
and the password quality policy.
Docker group membership is listed because docker run -v /:/host is a root shell.
SUID and SGID binaries and file capabilities across the root filesystem,
cross-referenced against the package database
so that only files no package owns get flagged.
The scan is -xdev, so a separate /home or /opt partition is not covered.
Sysctl hardening: rp_filter, syncookies, accept_redirects, kernel.randomize_va_space,
the fs.protected_* family and a few others.
AppArmor and SELinux state, /tmp mounted noexec,nosuid, time synchronisation,
persistent journald, auditd,
and how close the distribution release is to end of life.
On EC2 it also checks whether IMDSv1 answers without a token.
Privileged containers, socket ownership and permissions, published ports.
Whether fail2ban or CrowdSec is running,
whether the fail2ban sshd jail covers the port sshd actually listens on,
and failed logins over one 24 hour window from journalctl, falling back to lastb.
"Invalid user" counts as well as "Failed password",
so a key-only host does not look quiet when it is being scanned.
CPU sampled twice from /proc/stat, memory, swap,
space and inode usage for every local filesystem,
whether a reboot is pending,
and risky services such as telnet, rpcbind, avahi and cups by name.
curl -fsSLO https://<your-host>/postflight.sh
chmod +x postflight.sh
sudo ./postflight.shOr system wide:
sudo install -m 755 postflight.sh /usr/local/bin/postflightYou need bash 4 or newer and coreutils.
Everything else is optional:
when a tool a check depends on is missing, the check reports SKIP with the reason.
Run it as root.
Without root the script still runs, but every check that reads privileged state reports SKIP,
and it prints a notice to stderr saying so.
postflight [options]
Output options:
| Flag | Effect |
|---|---|
--format text|json|prom |
report format, text by default |
--json |
same as --format json |
--output FILE |
write the report to FILE instead of stdout |
--prom FILE |
also write Prometheus metrics to FILE |
--no-color / --color |
force colour off or on. NO_COLOR=1 works too |
--no-score |
leave out the score line |
--quiet |
print only the summary |
--issues |
list only the checks that need attention (WARN, FAIL, INFO) |
--progress / --no-progress |
live progress on stderr, on by default when stderr is a terminal |
Choosing what runs:
| Flag | Effect |
|---|---|
--only id1,id2 |
run only these checks |
--skip id1,id2 |
run everything except these |
--list |
print every check id and exit |
Automation:
| Flag | Effect |
|---|---|
--baseline FILE |
compare against the previous run in FILE, report what got worse, then update it |
--webhook URL |
POST a JSON summary when a check gets worse |
--ntfy URL |
POST a plain-text summary to an ntfy topic |
--ntfy-token TOKEN |
bearer token for a protected topic, or set NTFY_TOKEN |
--email ADDR |
mail the summary, using mail(1) or sendmail(1) |
--no-network |
make no outbound requests at all: public IP, IMDS probe, notifications |
--timeout SECONDS |
network timeout, 3 by default |
Exit codes:
| Code | Meaning |
|---|---|
0 |
nothing worse than PASS. SKIP and INFO do not affect it |
1 |
the worst result was WARN |
2 |
at least one FAIL |
3 |
bad arguments, or the script itself failed |
A CI gate that tolerates warnings but not failures:
sudo postflight --json --output audit.json || [ $? -le 1 ]While it runs, an interactive terminal gets a progress line on stderr:
[14/35] suid_sgid, and the two filesystem scans count seconds against their ceiling
([20/35] suid_sgid 45s/120s),
so a slow scan is distinguishable from a hang.
It is stderr, so it never mixes into the report, a redirect or --output,
and it turns itself off when stderr is not a terminal.
text is the default and colourises when stdout is a terminal.
It prints a system summary, one line per check,
then the counts, the score and the worst result.
json is one object with version, timestamp, system, summary and checks.
The summary carries the counts plus score, worst, exit_code and regressions.
Each check has id, name, status and message.
The script writes it directly, so the host does not need jq.
{
"summary": { "pass": 21, "warn": 6, "fail": 2, "skip": 5, "info": 1,
"score": 78, "worst": "FAIL", "exit_code": 2, "regressions": [] },
"checks": [
{"id": "firewall", "name": "Firewall", "status": "FAIL",
"message": "no active firewall (ufw inactive, iptables policy ACCEPT, 0 rule(s))"}
]
}prom is the Prometheus textfile collector format.
postflight_check{id,name} carries 0 for pass, 1 for warn, 2 for fail, 3 for skip and 4 for info,
alongside postflight_results_total{status}, postflight_score
and postflight_last_run_timestamp_seconds.
Use --prom FILE to write metrics alongside a report meant for a person to read.
The file is written through a temporary file and renamed,
so a collector never reads a half-written one.
sudo postflight --prom /var/lib/node_exporter/textfile_collector/postflight.prom--baseline FILE stores id<TAB>status for every check.
On the next run each check is compared against its stored result,
and anything that got worse is listed in the report and in the JSON regressions array.
The script then rewrites the file with mode 600.
With a baseline configured, --webhook, --ntfy and --email fire only when a check gets worse,
including PASS to WARN, which is usually what you want from a daily timer.
Without one they fire on every FAIL.
Use a self-hosted ntfy server or a random topic name:
on the public server, anyone who guesses the topic reads your hostname, open ports and user names.
The first run against a new baseline file notifies nobody, so a host that is already failing stays quiet until something changes.
sudo postflight --baseline /var/lib/postflight/baseline \
--ntfy https://ntfy.example.com/servers --ntfy-token tk_...There are no config files. The thresholds sit in a labelled block near the top of the script. Edit them there, or set the same name in the environment for a single run.
| Variable | Default | Meaning |
|---|---|---|
RESOURCE_WARN / RESOURCE_FAIL |
50 / 80 | disk, memory and CPU usage, in percent |
INODE_WARN / INODE_FAIL |
70 / 90 | inode usage, in percent |
LOGINS_WARN / LOGINS_FAIL |
25 / 100 | failed logins in the last 24 hours |
PUBLIC_PORTS_WARN / PUBLIC_PORTS_FAIL |
5 / 10 | publicly bound listening ports |
PASSWORD_MINLEN |
12 | shortest password the policy may allow |
CPU_SAMPLE |
0.5 | seconds between the two /proc/stat reads |
NET_TIMEOUT |
3 | seconds for any outbound request, same as --timeout |
RESOURCE_WARN=70 RESOURCE_FAIL=90 sudo -E postflightTwo more environment variables are read at runtime.
POSTFLIGHT_SCAN_TIMEOUT (120 by default) is how many seconds each whole-filesystem scan gets,
meaning the SUID/SGID and capability checks.
On a large or slow disk a scan that runs out of time reports WARN and says the result is incomplete.
Give it longer with POSTFLIGHT_SCAN_TIMEOUT=600 postflight --only suid_sgid.
NO_COLOR=1 does the same thing as --no-color.
POSTFLIGHT_TEST_ROOT=1 makes the script attempt the root-only checks without being root.
It exists for the test suite and grants nothing:
set it as an ordinary user and those checks report findings built from data they could not read.
Reports are written under umask 077,
because they list open ports, user names and key paths.
systemd/ holds a oneshot service and a daily timer.
sudo install -m 755 postflight.sh /usr/local/bin/postflight
sudo cp systemd/postflight.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now postflight.timerThe unit writes a JSON report to /var/log/postflight/
and keeps its baseline in /var/lib/postflight/.
Exit codes 0, 1 and 2 all count as success,
so a failing check does not turn into a failing unit.
Add --ntfy or --webhook to ExecStart to hear when a check gets worse.
The timer has a 30 minute randomised delay so that a fleet does not all scan at once.
journalctl -u postflight.service -n 50The unit sets TimeoutStartSec=20min, so a run that hangs fails the unit and the next tick starts clean.
Alert on a stale audit as well as on its findings:
time() - postflight_last_run_timestamp_seconds > 2 * 86400
./test_postflight.shThe suite puts stub versions of ss, ufw, iptables, nft and apt-get on PATH,
then checks the parsing that is easy to get wrong:
UDP sockets counted,
public split from loopback,
an empty ACCEPT ruleset reported as unprotected,
nftables credited when UFW is inactive,
only -security updates counted,
and the exit code following the worst result.
MIT. See LICENSE.
