Skip to content

Commit 3bc3ad0

Browse files
itcmsgrclaude
andcommitted
PR26.6: takeover preserves non-nftban authority (TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001)
dns2 (2026-04-30) install evidence reported `inet ssh_safety` (operator- retained kernel safety table) silently wiped during PR26.4 install. Survey traced root cause to `cli/lib/nftban/cli/cmd_firewall.sh` step 4 of `_firewall_rebuild_core`, which used an allowlist-regex sweep: ALLOWED_TABLES_PATTERN="^table (ip|ip6) (nftban|raw)$|^table inet (filter|nftban|nftban_install_emergency)$" ... if NOT matching ... nft delete table "$TABLE_SPEC" 2>/dev/null Anything not matching was silently deleted. `inet ssh_safety` failed the regex and was destroyed. The 2>/dev/null hid the only log signal. The same shape existed in `helpers/autoheal.sh` section 9 with an even tighter regex. Fix (6A) — replace both with a 4-class table classifier: cli/lib/nftban/core/nftban_table_classify.sh NFTBAN_OWNED -> flush, no nft-delete EXTERNAL_AUTHORITY_GHOST -> delete (CSF/firewalld iptables-nft compat) KERNEL_DEFAULT -> preserve silently (ip raw, ip6 raw) OPERATOR_SAFETY -> WARN to stderr, preserve, continue Default policy is WARN-and-preserve. Strict refuse / explicit purge modes are deferred to a future PR; PR26.6 must not change install success semantics for normal hosts. Lock-in (6B) — Go takeover behavior tests under switchop: - csf binary RENAMED to /usr/sbin/csf.disabled with sha256 match (reversible disarm; PR-26 amendment 2 already accepts this for restore-to-CSF preflight). NOT a destructive deletion. - /usr/sbin/lfd untouched (no mv, no rm) — mask-only contract. - /etc/csf, /usr/local/csf, /var/lib/csf preserved. - cron-backup manifest written before /etc/cron.d/{csf,lfd}-cron rm. - Go ghost cleanup also preserves operator and kernel-default tables (defense-in-depth, in case future refactors move logic there). Fixture (6C) — cli/lib/nftban/tests/test_table_classifier.sh: 27 assertions covering all 4 classes including `inet ssh_safety` survival. Pure shell test, runs without nft / root. Lab proof on lab2 (Ubuntu 24.04, go1.22.2): - bash test_table_classifier.sh: 27/27 PASS - go test -run TestPR26_6_ ./internal/installer/switchop/...: 5/5 PASS - go test ./internal/installer/...: all 17 installer packages PASS - go vet ./internal/installer/switchop/...: clean Out of scope (deferred): - --strict-unknown-tables / --purge-unknown-tables flags - cPanel/Plesk adapters (PR26.7 / PR26.8) - source-install payload work (PR26.5 already merged) - restore redesign - destructive dns2 retry Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1510e36 commit 3bc3ad0

5 files changed

Lines changed: 591 additions & 40 deletions

File tree

cli/lib/nftban/cli/cmd_firewall.sh

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,22 +1315,50 @@ _firewall_rebuild_core() {
13151315
[[ "$quiet" == "false" ]] && echo " Schema validation: PASSED" || true
13161316
fi
13171317

1318-
# Step 4: Remove rogue tables (keep only NFTBan tables)
1319-
[[ "$quiet" == "false" ]] && echo " [4/12] Removing rogue tables..."
1320-
# v1.48.0: Include SYNPROXY raw tables in allowed list
1321-
# v1.51.1: Include emergency install table (postinst lockout prevention)
1322-
local ALLOWED_TABLES_PATTERN="^table (ip|ip6) (nftban|raw)$|^table inet (filter|nftban|nftban_install_emergency)$"
1318+
# Step 4: Classify and clean ghost tables (PR26.6 / 6A).
1319+
# TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001 — replaces prior
1320+
# allowlist-sweep that silently deleted operator-retained tables
1321+
# such as `inet ssh_safety`. Default policy is WARN-and-preserve
1322+
# for OPERATOR_SAFETY; only EXTERNAL_AUTHORITY_GHOST is deleted.
1323+
[[ "$quiet" == "false" ]] && echo " [4/12] Classifying nft tables (preserve operator safety)..."
1324+
local _classify_lib="${NFTBAN_LIB_DIR:-/usr/lib/nftban}/core/nftban_table_classify.sh"
1325+
if [[ -f "$_classify_lib" ]]; then
1326+
# shellcheck source=/dev/null
1327+
source "$_classify_lib" 2>/dev/null || true
1328+
fi
1329+
13231330
local ALL_TABLES
13241331
ALL_TABLES=$(nft list tables 2>/dev/null || true)
13251332

13261333
while IFS= read -r table_line; do
13271334
[[ -z "$table_line" ]] && continue
1328-
if ! echo "$table_line" | grep -qE "$ALLOWED_TABLES_PATTERN"; then
1329-
local TABLE_SPEC="${table_line#table }"
1330-
if nft delete table "$TABLE_SPEC" 2>/dev/null; then
1331-
[[ "$quiet" == "false" ]] && echo " Deleted rogue table: $TABLE_SPEC" || true
1332-
fi
1335+
local TABLE_SPEC="${table_line#table }"
1336+
local _class=""
1337+
if declare -f nftban_classify_table_line &>/dev/null; then
1338+
_class="$(nftban_classify_table_line "$table_line")"
13331339
fi
1340+
case "$_class" in
1341+
"$TC_EXTERNAL_AUTHORITY_GHOST")
1342+
if nft delete table "$TABLE_SPEC" 2>/dev/null; then
1343+
[[ "$quiet" == "false" ]] && echo " Deleted external-authority ghost table: $TABLE_SPEC" || true
1344+
fi
1345+
;;
1346+
"$TC_OPERATOR_SAFETY")
1347+
# PR26.6 invariant — preserve operator-retained tables.
1348+
# Emit warning to stderr so it lands in install logs.
1349+
echo "WARNING: preserving non-nftban operator table: $TABLE_SPEC (TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001)" >&2
1350+
;;
1351+
"$TC_NFTBAN_OWNED"|"$TC_KERNEL_DEFAULT")
1352+
# nftban-owned: flushed in step 5 below.
1353+
# kernel default (ip raw / ip6 raw): preserve silently.
1354+
: ;;
1355+
*)
1356+
# Classifier unavailable (lib missing) — preserve, do
1357+
# not silently delete. Better to leave a foreign table
1358+
# than to wipe operator state.
1359+
echo "WARNING: table classifier unavailable; preserving: $TABLE_SPEC" >&2
1360+
;;
1361+
esac
13341362
done <<< "$ALL_TABLES"
13351363

13361364
# Step 5: Flush + load (safe — we validated above)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env bash
2+
# =============================================================================
3+
# NFTBan v1.100 Amendment 4 - nft Table Classifier (PR26.6 / 6A)
4+
# =============================================================================
5+
# SPDX-License-Identifier: MPL-2.0
6+
# meta:name="nftban_table_classify"
7+
# meta:type="lib"
8+
# meta:version="1.100.0"
9+
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
10+
# meta:created_date="2026-04-30"
11+
# meta:description="Classify nft tables before destructive cleanup — TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001"
12+
# meta:inventory.files="cli/lib/nftban/core/nftban_table_classify.sh"
13+
# meta:inventory.binaries=""
14+
# meta:inventory.env_vars=""
15+
# meta:inventory.config_files=""
16+
# meta:inventory.systemd_units=""
17+
# meta:inventory.network=""
18+
# meta:inventory.privileges="none"
19+
#
20+
# Invariant TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001:
21+
# During takeover and rebuild, nftban may disable external firewall
22+
# authority through reversible lifecycle operations, but must not
23+
# destructively delete non-nftban-owned authority or operator-safety
24+
# assets.
25+
#
26+
# Replaces the prior allowlist-sweep pattern that silently called
27+
# nft delete table <every-non-allowlisted-table>
28+
# which on dns2 (2026-04-30) wiped operator-retained inet ssh_safety.
29+
#
30+
# Classes:
31+
# NFTBAN_OWNED — flush/delete authorized
32+
# EXTERNAL_AUTHORITY_GHOST — delete authorized (CSF/firewalld iptables-nft compat)
33+
# KERNEL_DEFAULT — preserve silently (ip raw, ip6 raw)
34+
# OPERATOR_SAFETY — preserve, emit warning (default policy in PR26.6)
35+
# =============================================================================
36+
37+
set -Eeuo pipefail
38+
39+
[[ -n "${_NFTBAN_TABLE_CLASSIFY_LOADED:-}" ]] && return 0
40+
_NFTBAN_TABLE_CLASSIFY_LOADED=1
41+
42+
# shellcheck disable=SC2034
43+
readonly TC_NFTBAN_OWNED="NFTBAN_OWNED"
44+
# shellcheck disable=SC2034
45+
readonly TC_EXTERNAL_AUTHORITY_GHOST="EXTERNAL_AUTHORITY_GHOST"
46+
# shellcheck disable=SC2034
47+
readonly TC_KERNEL_DEFAULT="KERNEL_DEFAULT"
48+
# shellcheck disable=SC2034
49+
readonly TC_OPERATOR_SAFETY="OPERATOR_SAFETY"
50+
51+
# nftban_classify_table — classify a single nft table by family + name.
52+
#
53+
# Input (positional):
54+
# $1 family — one of: ip, ip6, inet, arp, bridge, netdev
55+
# $2 name — table name
56+
# Output:
57+
# prints classification token to stdout (one of TC_* values)
58+
# Exit code: always 0
59+
#
60+
# Pinned table sets are kept in this single function so
61+
# install-side, rebuild-side, and autoheal-side all agree.
62+
nftban_classify_table() {
63+
local family="$1"
64+
local name="$2"
65+
local spec="${family} ${name}"
66+
67+
case "$spec" in
68+
# NFTBan-owned tables
69+
"ip nftban"|"ip6 nftban"|"inet nftban"|"inet nftban_install_emergency")
70+
echo "$TC_NFTBAN_OWNED"
71+
return 0
72+
;;
73+
# Kernel default empty tables (always present on EL9+, harmless)
74+
"ip raw"|"ip6 raw")
75+
echo "$TC_KERNEL_DEFAULT"
76+
return 0
77+
;;
78+
# External firewall ghost tables — created by iptables-nft compat
79+
# layer (CSF/lfd, firewalld, fail2ban, docker). Delete authorized
80+
# during takeover-driven rebuild.
81+
"ip filter"|"ip6 filter"|\
82+
"ip nat"|"ip6 nat"|\
83+
"ip mangle"|"ip6 mangle"|\
84+
"ip security"|"ip6 security"|\
85+
"inet firewalld"|"inet filter")
86+
echo "$TC_EXTERNAL_AUTHORITY_GHOST"
87+
return 0
88+
;;
89+
*)
90+
# Anything else — including operator-retained tables such as
91+
# `inet ssh_safety` — is OPERATOR_SAFETY. PR26.6 default policy
92+
# is WARN-and-preserve.
93+
echo "$TC_OPERATOR_SAFETY"
94+
return 0
95+
;;
96+
esac
97+
}
98+
99+
# nftban_classify_table_line — accept "table <family> <name>" line as
100+
# emitted by `nft list tables` and route through nftban_classify_table.
101+
nftban_classify_table_line() {
102+
local line="$1"
103+
# Strip leading "table "
104+
local rest="${line#table }"
105+
# Split on first whitespace
106+
local family="${rest%% *}"
107+
local name="${rest#* }"
108+
if [[ -z "$family" || -z "$name" || "$family" == "$name" ]]; then
109+
echo "$TC_OPERATOR_SAFETY"
110+
return 0
111+
fi
112+
nftban_classify_table "$family" "$name"
113+
}
114+
115+
# nftban_table_should_delete_for_takeover — true (rc 0) if the table
116+
# may be destructively deleted during takeover-driven rebuild cleanup.
117+
#
118+
# NFTBAN_OWNED → false (the rebuild flushes nftban tables itself, does
119+
# not nft-delete them — preserving set state references).
120+
# EXTERNAL_AUTHORITY_GHOST → true.
121+
# KERNEL_DEFAULT, OPERATOR_SAFETY → false.
122+
nftban_table_should_delete_for_takeover() {
123+
local family="$1"
124+
local name="$2"
125+
local class
126+
class="$(nftban_classify_table "$family" "$name")"
127+
[[ "$class" == "$TC_EXTERNAL_AUTHORITY_GHOST" ]]
128+
}

cli/lib/nftban/helpers/autoheal.sh

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -439,46 +439,59 @@ else
439439
fi
440440

441441
# =============================================================================
442-
# 9. Remove rogue nftables tables (v1.17.6)
443-
# =============================================================================
444-
# iptables-nft, docker, fail2ban can create rogue tables like "ip raw", "ip filter"
445-
# These interfere with NFTBan and cause schema corruption
446-
log_info "Checking for rogue nftables tables..."
447-
448-
# Allowed tables (v1.18.0: ONLY ip/ip6 nftban - NO inet tables!)
449-
# CVE-2025-NFTBAN-001: inet filter at priority 0 bypasses NFTBan protection
450-
ALLOWED_TABLES_PATTERN="^table (ip|ip6) nftban$"
442+
# 9. Classify and clean ghost nftables tables (PR26.6 / 6A)
443+
# =============================================================================
444+
# TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001 — replaces prior
445+
# allowlist-sweep that silently deleted operator-retained tables such
446+
# as `inet ssh_safety`. Default policy is WARN-and-preserve for
447+
# OPERATOR_SAFETY; only EXTERNAL_AUTHORITY_GHOST is deleted.
448+
log_info "Classifying nft tables (preserve operator safety)..."
449+
450+
_NFTBAN_TABLE_CLASSIFY_LIB="${NFTBAN_LIB_DIR:-/usr/lib/nftban}/core/nftban_table_classify.sh"
451+
if [[ -f "$_NFTBAN_TABLE_CLASSIFY_LIB" ]]; then
452+
# shellcheck source=/dev/null
453+
source "$_NFTBAN_TABLE_CLASSIFY_LIB" 2>/dev/null || true
454+
fi
451455

452456
# Get all tables
453457
ALL_TABLES=$(nft list tables 2>/dev/null || true)
454458

455-
# Find and remove rogue tables
456-
ROGUE_COUNT=0
459+
GHOST_COUNT=0
460+
SAFETY_COUNT=0
457461
while IFS= read -r table_line; do
458462
[[ -z "$table_line" ]] && continue
459-
460-
# Check if table is allowed
461-
if ! echo "$table_line" | grep -qE "$ALLOWED_TABLES_PATTERN"; then
462-
ROGUE_COUNT=$((ROGUE_COUNT + 1))
463-
log_warn "Detected rogue table: $table_line"
464-
465-
# Extract table family and name (e.g., "table ip raw" -> "ip raw")
466-
TABLE_SPEC="${table_line#table }"
467-
468-
# Delete the rogue table
469-
# v1.19.20 FIX
470-
if nft delete table "$TABLE_SPEC" 2>/dev/null; then
471-
log_info "✅ Deleted rogue table: $TABLE_SPEC"
472-
else
473-
log_error "Failed to delete rogue table: $TABLE_SPEC"
474-
fi
463+
TABLE_SPEC="${table_line#table }"
464+
_class=""
465+
if declare -f nftban_classify_table_line &>/dev/null; then
466+
_class="$(nftban_classify_table_line "$table_line")"
475467
fi
468+
case "$_class" in
469+
"$TC_EXTERNAL_AUTHORITY_GHOST")
470+
log_warn "Detected external-authority ghost table: $table_line"
471+
if nft delete table "$TABLE_SPEC" 2>/dev/null; then
472+
log_info "✅ Deleted external-authority ghost table: $TABLE_SPEC"
473+
GHOST_COUNT=$((GHOST_COUNT + 1))
474+
else
475+
log_error "Failed to delete ghost table: $TABLE_SPEC"
476+
fi
477+
;;
478+
"$TC_OPERATOR_SAFETY")
479+
# PR26.6 invariant — preserve operator-retained tables.
480+
log_warn "Preserving non-nftban operator table: $TABLE_SPEC (TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001)"
481+
SAFETY_COUNT=$((SAFETY_COUNT + 1))
482+
;;
483+
"$TC_NFTBAN_OWNED"|"$TC_KERNEL_DEFAULT")
484+
: ;;
485+
*)
486+
log_warn "Table classifier unavailable; preserving: $TABLE_SPEC"
487+
;;
488+
esac
476489
done <<< "$ALL_TABLES"
477490

478-
if [[ $ROGUE_COUNT -eq 0 ]]; then
479-
log_info "✅ No rogue tables detected - schema clean"
491+
if [[ $GHOST_COUNT -eq 0 && $SAFETY_COUNT -eq 0 ]]; then
492+
log_info "✅ No ghost tables, no operator-safety tables — schema clean"
480493
else
481-
log_info "Removed $ROGUE_COUNT rogue table(s)"
494+
log_info "Cleaned $GHOST_COUNT ghost table(s); preserved $SAFETY_COUNT operator-safety table(s)"
482495
fi
483496

484497
# =============================================================================
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bash
2+
# =============================================================================
3+
# NFTBan PR26.6 / 6C — nft Table Classifier Fixture Test
4+
# =============================================================================
5+
# SPDX-License-Identifier: MPL-2.0
6+
# meta:name="test_table_classifier"
7+
# meta:type="test"
8+
# meta:owner="Antonios Voulvoulis <contact@nftban.com>"
9+
# meta:created_date="2026-04-30"
10+
# meta:description="Locks TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001 — operator-safety tables (e.g. inet ssh_safety) survive rebuild classification"
11+
# meta:inventory.files="cli/lib/nftban/tests/test_table_classifier.sh"
12+
# meta:inventory.binaries=""
13+
# meta:inventory.env_vars=""
14+
# meta:inventory.config_files=""
15+
# meta:inventory.systemd_units=""
16+
# meta:inventory.network=""
17+
# meta:inventory.privileges="none"
18+
# =============================================================================
19+
set -Eeuo pipefail
20+
21+
LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/core/nftban_table_classify.sh"
22+
if [[ ! -f "$LIB" ]]; then
23+
echo "FAIL: classifier lib not found at $LIB" >&2
24+
exit 1
25+
fi
26+
# shellcheck source=/dev/null
27+
source "$LIB"
28+
29+
FAIL=0
30+
expect() {
31+
local got="$1" want="$2" label="$3"
32+
if [[ "$got" != "$want" ]]; then
33+
echo "FAIL: $label — got '$got', want '$want'" >&2
34+
FAIL=1
35+
else
36+
echo "PASS: $label ($got)"
37+
fi
38+
}
39+
40+
# Class 1: NFTBAN_OWNED
41+
expect "$(nftban_classify_table ip nftban)" "$TC_NFTBAN_OWNED" "ip nftban → NFTBAN_OWNED"
42+
expect "$(nftban_classify_table ip6 nftban)" "$TC_NFTBAN_OWNED" "ip6 nftban → NFTBAN_OWNED"
43+
expect "$(nftban_classify_table inet nftban_install_emergency)" "$TC_NFTBAN_OWNED" "inet nftban_install_emergency → NFTBAN_OWNED"
44+
45+
# Class 2: KERNEL_DEFAULT (ip raw / ip6 raw — preserve silently)
46+
expect "$(nftban_classify_table ip raw)" "$TC_KERNEL_DEFAULT" "ip raw → KERNEL_DEFAULT"
47+
expect "$(nftban_classify_table ip6 raw)" "$TC_KERNEL_DEFAULT" "ip6 raw → KERNEL_DEFAULT"
48+
49+
# Class 3: EXTERNAL_AUTHORITY_GHOST (CSF/firewalld iptables-nft compat)
50+
for spec in "ip filter" "ip6 filter" "ip nat" "ip6 nat" "ip mangle" "ip6 mangle" "ip security" "ip6 security" "inet firewalld" "inet filter"; do
51+
fam="${spec%% *}"; nm="${spec#* }"
52+
expect "$(nftban_classify_table "$fam" "$nm")" "$TC_EXTERNAL_AUTHORITY_GHOST" "$spec → EXTERNAL_AUTHORITY_GHOST"
53+
done
54+
55+
# Class 4: OPERATOR_SAFETY (everything else — must include ssh_safety, dns2 evidence)
56+
expect "$(nftban_classify_table inet ssh_safety)" "$TC_OPERATOR_SAFETY" "inet ssh_safety → OPERATOR_SAFETY (PR26.6 root cause)"
57+
expect "$(nftban_classify_table inet test_operator_safety)" "$TC_OPERATOR_SAFETY" "inet test_operator_safety → OPERATOR_SAFETY"
58+
expect "$(nftban_classify_table ip custom_ops)" "$TC_OPERATOR_SAFETY" "ip custom_ops → OPERATOR_SAFETY"
59+
expect "$(nftban_classify_table bridge filter)" "$TC_OPERATOR_SAFETY" "bridge filter → OPERATOR_SAFETY"
60+
expect "$(nftban_classify_table netdev ingress)" "$TC_OPERATOR_SAFETY" "netdev ingress → OPERATOR_SAFETY"
61+
62+
# Line-form classifier (nft list tables output shape)
63+
expect "$(nftban_classify_table_line 'table inet ssh_safety')" "$TC_OPERATOR_SAFETY" "line: table inet ssh_safety → OPERATOR_SAFETY"
64+
expect "$(nftban_classify_table_line 'table ip nftban')" "$TC_NFTBAN_OWNED" "line: table ip nftban → NFTBAN_OWNED"
65+
expect "$(nftban_classify_table_line 'table ip filter')" "$TC_EXTERNAL_AUTHORITY_GHOST" "line: table ip filter → EXTERNAL_AUTHORITY_GHOST"
66+
expect "$(nftban_classify_table_line 'table ip raw')" "$TC_KERNEL_DEFAULT" "line: table ip raw → KERNEL_DEFAULT"
67+
68+
# Takeover delete predicate
69+
if nftban_table_should_delete_for_takeover ip filter; then
70+
echo "PASS: takeover deletes ip filter"
71+
else
72+
echo "FAIL: takeover should delete ip filter (EXTERNAL_AUTHORITY_GHOST)" >&2; FAIL=1
73+
fi
74+
if nftban_table_should_delete_for_takeover inet ssh_safety; then
75+
echo "FAIL: takeover MUST NOT delete inet ssh_safety (OPERATOR_SAFETY)" >&2; FAIL=1
76+
else
77+
echo "PASS: takeover preserves inet ssh_safety (PR26.6 invariant)"
78+
fi
79+
if nftban_table_should_delete_for_takeover ip nftban; then
80+
echo "FAIL: takeover should not nft-delete ip nftban (NFTBAN_OWNED — flushed elsewhere)" >&2; FAIL=1
81+
else
82+
echo "PASS: takeover does not nft-delete ip nftban (flush-only path)"
83+
fi
84+
if nftban_table_should_delete_for_takeover ip raw; then
85+
echo "FAIL: takeover should not delete ip raw (KERNEL_DEFAULT)" >&2; FAIL=1
86+
else
87+
echo "PASS: takeover preserves ip raw (KERNEL_DEFAULT)"
88+
fi
89+
90+
if [[ "$FAIL" -ne 0 ]]; then
91+
echo ""
92+
echo "RESULT: FAIL"
93+
exit 1
94+
fi
95+
echo ""
96+
echo "RESULT: PASS — TAKEOVER-PRESERVES-NON-NFTBAN-AUTHORITY-001 classifier locked"

0 commit comments

Comments
 (0)