Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

postflight

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

postflight report

What it checks

Run ./postflight.sh --list to print the id of every check. The groups below describe what those checks look at.

SSH

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.

Network exposure

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.

Firewall

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.

Patching

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.

Accounts and privileges

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.

Files

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.

Kernel and platform

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.

Docker

Privileged containers, socket ownership and permissions, published ports.

Intrusion attempts

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.

Resources

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.

Install

curl -fsSLO https://<your-host>/postflight.sh
chmod +x postflight.sh
sudo ./postflight.sh

Or system wide:

sudo install -m 755 postflight.sh /usr/local/bin/postflight

You 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.

Usage

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 ]

Output formats

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

Baselines and notifications

--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_...

Configuration

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 postflight

Two 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.

Scheduled runs

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.timer

The 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 50

The 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

Tests

./test_postflight.sh

The 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.

Licence

MIT. See LICENSE.

About

One-file Bash audit for Linux servers: security and health checks, each with the exact command that fixes it. Text, JSON or Prometheus output

Topics

Resources

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages