-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_postflight.sh
More file actions
executable file
·253 lines (227 loc) · 12.3 KB
/
Copy pathtest_postflight.sh
File metadata and controls
executable file
·253 lines (227 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
#!/usr/bin/env bash
# Self-check for postflight. Stubs the tools whose output drove the bugs this
# rewrite fixes (ss, ufw/iptables/nft, apt-get) and asserts the verdicts.
# ./test_postflight.sh
set -uo pipefail
cd "$(dirname "$0")"
AUDIT="$PWD/postflight.sh"
STUB=$(mktemp -d); trap 'rm -rf "$STUB"' EXIT
FAILED=0
stub() { printf '#!/bin/sh\n%s\n' "$2" > "$STUB/$1"; chmod +x "$STUB/$1"; }
run() { # run <checks> -> report text
PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 \
"$AUDIT" --no-network --no-color --only "$1" 2>/dev/null
}
assert_has() { # assert_has <haystack> <needle> <label>
if grep -qF -- "$2" <<< "$1"; then
echo "ok - $3"
else
echo "FAIL - $3"; echo " expected to find: $2"; echo " in: $1"; FAILED=1
fi
}
# --- every registered check has an implementation ---------------------------
for id in $("$AUDIT" --list); do
grep -q "^chk_${id}()" "$AUDIT" || { echo "FAIL - no chk_${id} function"; FAILED=1; }
done
echo "ok - every registered check has a chk_ function"
# --- ports: UDP counted, public split from loopback -------------------------
# UDP sockets show UNCONN, not LISTEN, and public must be split from loopback
# by bind address rather than derived from the same expression as the total.
stub ss 'cat <<EOT
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=3))
tcp LISTEN 0 244 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=2,fd=5))
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=3,fd=6))
udp UNCONN 0 0 [::1]:323 [::]:* users:(("chronyd",pid=4,fd=7))
EOT'
out=$(run listening_ports)
assert_has "$out" "2 public, 2 loopback-only" "ports split public from loopback"
assert_has "$out" "udp/68 dhclient" "UDP sockets are not dropped"
assert_has "$out" "tcp/22 sshd" "owning process is reported"
# --- firewall: nftables protects even when UFW is inactive ------------------
stub ufw 'echo "Status: inactive"'
stub iptables 'echo "-P INPUT ACCEPT"'
stub ip6tables 'echo "-P INPUT ACCEPT"'
stub nft 'cat <<EOT
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
tcp dport 22 accept
}
}
EOT'
out=$(run firewall)
assert_has "$out" "[PASS]" "inactive UFW + working nftables is not a FAIL"
assert_has "$out" "nftables" "nftables is credited as the active firewall"
# --- firewall: an empty ACCEPT ruleset is not protection --------------------
# The chain exists but the policy is ACCEPT with no rules: that is not a pass.
stub nft 'exit 0'
out=$(run firewall)
assert_has "$out" "[FAIL]" "empty ACCEPT policy is reported as unprotected"
assert_has "$out" "policy ACCEPT, 0 rule(s)" "the INPUT policy and rule count are shown"
# --- updates: only the -security pocket counts ------------------------------
stub apt-get 'cat <<EOT
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.15 Ubuntu:22.04/jammy-security [amd64])
Inst vim [2:8.2] (2:8.3 Ubuntu:22.04/jammy-updates [amd64])
EOT'
out=$(run updates)
assert_has "$out" "1 security update(s) pending: libssl3" "non-security updates are not counted as security"
# --- SUID scan: several files must stay several files -----------------------
# NUL-delimited output cannot travel through $(...): bash drops the NULs and
# every path ends up concatenated into one.
stub find 'printf "/usr/bin/sudo\\0/usr/bin/passwd\\0/opt/planted\\0"'
stub dpkg-query 'case "$*" in *"/usr/bin/sudo"*) echo "sudo: /usr/bin/sudo"; echo "passwd: /usr/bin/passwd" ;; esac; exit 0'
out=$(run suid_sgid)
assert_has "$out" "3 setuid/setgid file(s)" "every path in a multi-file scan is counted separately"
assert_has "$out" "/opt/planted" "the unowned path is named on its own"
if grep -q "/usr/bin/sudo/usr/bin/passwd" <<< "$out"; then
echo "FAIL - paths were concatenated into one"; FAILED=1
else
echo "ok - paths are not glued together"
fi
rm -f "$STUB/find" "$STUB/dpkg-query"
# --- JSON must be UTF-8: a file name is an arbitrary byte string -------------
stub find 'printf "/home/bob/ev\\377il\\0"'
stub dpkg-query 'exit 1'
js=$(PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --only suid_sgid --json 2>/dev/null)
if python3 -c 'import json,sys; json.load(sys.stdin)' <<< "$js" 2>/dev/null; then
echo "ok - an invalid UTF-8 byte in a file name still yields valid JSON"
else
echo "FAIL - --json output is not valid UTF-8"; FAILED=1
fi
rm -f "$STUB/find" "$STUB/dpkg-query"
# --- firewall: ufw active is not ufw denying --------------------------------
stub ufw 'case "$*" in *verbose*) echo "Status: active"; echo "Default: allow (incoming), allow (outgoing)";; *) echo "Status: active";; esac'
stub iptables 'echo "-P INPUT ACCEPT"; echo "-A INPUT -j ufw-before-input"'
stub ip6tables 'echo "-P INPUT ACCEPT"'
stub nft 'exit 0'
out=$(run firewall)
assert_has "$out" "[FAIL]" "ufw with a default allow policy is not a firewall"
assert_has "$out" "default allow incoming" "the report says why ufw does not count"
# --- firewall: a blocklist is not a default deny ----------------------------
stub ufw 'exit 1'
stub iptables 'echo "-P INPUT ACCEPT"; echo "-A INPUT -m set --match-set crowdsec-blacklists src -j DROP"'
out=$(run firewall)
assert_has "$out" "[FAIL]" "a source-matched DROP is a blocklist, not protection"
# --- firewall: IPv6 on an nftables-backed host ------------------------------
# iptables is the nftables shim on every current distro, so its own chains come
# back from nft list ruleset. The v6 verdict must still come from a v6 hook.
stub ufw 'exit 1'
stub iptables 'echo "-P INPUT DROP"'
stub ip6tables 'echo "-P INPUT ACCEPT"'
stub ip 'echo "inet6 2001:db8::1/64 scope global"'
stub nft 'printf "table ip filter {\n\tchain INPUT {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n"'
out=$(run firewall)
assert_has "$out" "IPv6 INPUT is open" "an ip-family-only nftables ruleset does not vouch for IPv6"
stub nft 'printf "table inet filter {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n"'
out=$(run firewall)
assert_has "$out" "[PASS]" "an inet table covers both families"
rm -f "$STUB/ufw" "$STUB/iptables" "$STUB/ip6tables" "$STUB/ip" "$STUB/nft"
# --- firewall: an INPUT rule that filters nothing is not a firewall ----------
# fail2ban, crowdsec and docker all add rules to INPUT without dropping anything.
stub ufw 'exit 1'
stub nft 'exit 0'
stub iptables 'echo "-P INPUT ACCEPT"; echo "-A INPUT -p tcp -m multiport --dports 22 -j f2b-sshd"'
stub ip6tables 'echo "-P INPUT ACCEPT"'
out=$(run firewall)
assert_has "$out" "[FAIL]" "a fail2ban jump rule under an ACCEPT policy is not counted as protection"
rm -f "$STUB/ufw" "$STUB/nft" "$STUB/iptables" "$STUB/ip6tables"
stub ss "printf 'tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"\\033[2Kx\",pid=1,fd=3))\\n'"
js=$(PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --only listening_ports --json 2>/dev/null)
if python3 -c 'import json,sys; json.load(sys.stdin)' <<< "$js" 2>/dev/null; then
echo "ok - a control character in a process name still yields valid JSON"
else
echo "FAIL - --json output does not parse"; FAILED=1
fi
rm -f "$STUB/ss"
# --- the other two output formats --------------------------------------------
out=$(run swap)
prom=$(PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --only swap --format prom 2>/dev/null)
assert_has "$prom" "# TYPE postflight_check gauge" "prom output carries a TYPE line"
assert_has "$prom" "postflight_score" "prom output carries the score"
# --- baseline: a selected run must not drop the checks it did not run --------
BL=$(mktemp)
PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --only swap,cpu_usage --baseline "$BL" >/dev/null 2>&1
PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --only swap --baseline "$BL" >/dev/null 2>&1
if [ "$(wc -l < "$BL")" -eq 2 ]; then
echo "ok - --only keeps the baseline entries for checks that did not run"
else
echo "FAIL - the baseline shrank to the selected checks"; FAILED=1
fi
rm -f "$BL"
# --- the unit runs what the README installs ----------------------------------
if grep -q 'ExecStart=/usr/local/bin/postflight ' systemd/postflight.service &&
grep -q 'install -m 755 postflight.sh /usr/local/bin/postflight$' README.md; then
echo "ok - the systemd unit executes the path the README installs"
else
echo "FAIL - unit ExecStart and README install path disagree"; FAILED=1
fi
# --- the default SSH port is reported ---------------------------------------
stub sshd 'echo port 22'
out=$(run ssh_port)
assert_has "$out" "[WARN]" "the default port is reported"
rm -f "$STUB/sshd"
# --- fail2ban: "get sshd port" is not a command on 0.11+ --------------------
# It answers with a sentence, which must never be treated as a port number.
stub systemctl 'exit 0'
stub sshd 'echo port 22'
stub fail2ban-client 'case "$1" in
status) [ -z "${2-}" ] && { printf "Jail list:\tsshd\n"; exit 0; }; exit 0 ;;
get) echo "Invalid command (no get action or not yet implemented)"; exit 0 ;;
esac'
stub iptables 'echo "-A INPUT -p tcp -m multiport --dports 22 -j f2b-sshd"'
out=$(run fail2ban_port)
assert_has "$out" "[PASS]" "the port comes from the rule fail2ban installed, not from the client's error text"
if grep -qi "Invalidcommand" <<< "$out"; then
echo "FAIL - an error message was parsed as a port"; FAILED=1
else
echo "ok - an error message is not reported as a port"
fi
stub sshd 'echo port 2222'
out=$(run fail2ban_port)
assert_has "$out" "bans port 22 but sshd listens on 2222" "a real port mismatch is still caught"
stub iptables 'exit 0'
out=$(run fail2ban_port)
assert_has "$out" "[INFO]" "an unknowable port is reported as INFO, not FAIL"
rm -f "$STUB/systemctl" "$STUB/sshd" "$STUB/fail2ban-client" "$STUB/iptables"
# --- the report says whether anyone else can still get in -------------------
stub sshd 'echo permitrootlogin yes; echo passwordauthentication yes; echo kbdinteractiveauthentication yes; echo port 22'
stub getent 'case "$1" in group) echo "sudo:x:27:" ;; passwd) echo "root:x:0:0::/root:/bin/bash" ;; esac'
out=$(run ssh_root_login,ssh_password_auth)
assert_has "$out" "NO other account has an SSH key" "the lockout risk is stated when no other admin has a key"
assert_has "$out" "no account has an SSH key yet" "the password finding says a key must come first"
mkdir -p "$STUB/home/deploy/.ssh" && echo "ssh-ed25519 AAAA demo" > "$STUB/home/deploy/.ssh/authorized_keys"
stub getent "case \"\$1\" in group) echo 'sudo:x:27:deploy' ;; passwd) echo 'deploy:x:1000:1000::$STUB/home/deploy:/bin/bash' ;; esac"
out=$(run ssh_root_login)
assert_has "$out" "deploy can still log in with a key" "an admin with a key is named"
rm -rf "$STUB/getent" "$STUB/home" "$STUB/sshd"
# --- merged /usr: dpkg records /bin/x, the filesystem reports /usr/bin/x ------
stub find 'printf "/usr/bin/fusermount3\\0"' # the real scan is -print0
stub dpkg-query 'case "$*" in *"/bin/fusermount3"*) echo "fuse3: /bin/fusermount3" ;; esac; exit 0'
out=$(run suid_sgid)
assert_has "$out" "all owned by installed packages" "a /bin path in dpkg matches the /usr/bin file on disk"
rm -f "$STUB/find" "$STUB/dpkg-query"
# --- file capabilities: the path is parsed out of the getcap line ------------
# getcap prints "path cap_net_raw=ep"; the caps must not end up in the path we
# hand to dpkg, or every file looks unowned.
stub getcap 'exit 0'
: > "$STUB/planted" # the check drops paths that no longer exist
stub find "cat <<EOT
/usr/bin/ping cap_net_raw=ep
$STUB/planted cap_sys_admin=ep
EOT"
stub dpkg-query 'case "$*" in *"/usr/bin/ping"*) echo "iputils-ping: /usr/bin/ping" ;; esac; exit 0'
out=$(run file_caps)
assert_has "$out" "$STUB/planted" "an unowned capability file is reported"
if grep -q "/usr/bin/ping cap_net_raw" <<< "$out"; then
echo "FAIL - the capability string leaked into the path"; FAILED=1
else
echo "ok - a package-owned capability file is not flagged"
fi
rm -f "$STUB/find" "$STUB/getcap" "$STUB/dpkg-query" "$STUB/planted"
# --- exit code reflects the worst result ------------------------------------
PATH="$STUB:$PATH" POSTFLIGHT_TEST_ROOT=1 "$AUDIT" --no-network --no-color --only firewall >/dev/null 2>&1
[ $? -eq 2 ] && echo "ok - a FAIL exits 2" || { echo "FAIL - expected exit 2"; FAILED=1; }
"$AUDIT" --no-network --no-color --only accounts_uid0 >/dev/null 2>&1
[ $? -eq 0 ] && echo "ok - an all-PASS run exits 0" || { echo "FAIL - expected exit 0"; FAILED=1; }
[ "$FAILED" = 0 ] && echo "all tests passed" || echo "TESTS FAILED"
exit "$FAILED"