Skip to content

Commit bd7ea75

Browse files
authored
feat(v1.100 PR-22B): lifecycle truth repair — dry-run honesty + history gating + authority alignment + panel consent (#482)
Closes the systemic install/update audit findings. Scope-locked boundary repair: - Dry-run honesty: `--mode=install --dry-run` refused explicitly; `--repair --dry-run` refused; `StateFile.DryRun` suppresses `Transition` writes; `update_dryrun.go` no longer writes `update_plan.json`. - History gating: new `state.IsApplyTerminal(s)` explicit allowlist; `writeHistory` gated `!cfg.dryRun && IsApplyTerminal(sf.State)` — structural, not mode-heuristic. - Authority: canonical `IsNftbanAuthoritative(exec)` (table + chain + daemon) — used by both `authority.Classify` and `update.Preflight` P-1; new `Ambiguous` variant for orphan-table/daemon-down; `phaseSwitch` routes `Ambiguous` through emergency-SSH injection. - Panel consent: silent panel auto-approve replaced with `--panel-auto-takeover` default-off flag + `NFTBAN_PANEL_AUTO_TAKEOVER` env mirror. - Lifecycle bridge: real `DryRun` wired from `cfg.dryRun`; `observePlan`/`mapAuthority` switches pinned to `authority.Decision` constants (fixes pre-existing UPPERCASE-vs-lowercase bug that silently mapped every consumer to `PreserveAuthority` from v1.98). - Flag validation: `--takeover+--dry-run`, `--rpm+--deb`, `--force-delete-operator-config` without `--purge` all rejected. - CI: new `ci-install-canonization.yml` (G3-IN-REFUSE-DRY-RUN + G3-IN-FLAG-COMBOS); tightened `ci-update-canonization.yml` G3-U3 with history + state hard assertions, no `|| true`; extended G3-U5..U10 grep scope + patterns. - Reusable `audit.PurityHarness` at `internal/installer/audit/harness.go`, adopted by uninstall + new update purity tests. Three re-audit blockers closed in commit 966a175: refuse `--repair --dry-run`; port uninstall test to `PurityHarness` + add update purity test; fix `IsApplyTerminal` alias doc typo; plus `nft list chain ip nftban input` added to `ApplyWhitelist` for the tightened predicate. Pre-PR-23 blockers (not in scope; tracked as follow-up PRs): 1. Prior-authority record hardening (timestamp + installer version + active_at_install semantics) 2. External-firewall detection unification 3. Kernel/service snapshot CI gate 4. Exec-trace CI gate 5. Auto-elevate uninstall shim removal gate 6. Payload integrity minimum checks Depends on: #481 (PR-22A) Refs: V1100_LIFECYCLE_COMPLETION_CONTRACT.md §13 (frozen 2026-04-19)
1 parent 6d79246 commit bd7ea75

22 files changed

Lines changed: 1353 additions & 298 deletions
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# =============================================================================
2+
# NFTBan — CI: Install Canonization Gate (v1.100 PR-22B slice)
3+
# =============================================================================
4+
# SPDX-License-Identifier: MPL-2.0
5+
#
6+
# Purpose:
7+
# G3-IN-REFUSE-DRY-RUN — `--mode=install --dry-run` must REFUSE with a
8+
# clear error and a non-zero exit code. PR-22B
9+
# does not add install preview capability; it
10+
# removes false dry-run semantics by refusing
11+
# unsupported install dry-run invocations.
12+
#
13+
# G3-IN-FLAG-COMBOS — operator-error flag combinations are rejected
14+
# up front rather than silently accepted.
15+
#
16+
# Audit basis: extended audit §4.A ("install dry-run is dishonest today")
17+
# and §4.H ("CLI flag semantics").
18+
#
19+
# =============================================================================
20+
21+
name: Install Canonization Gate
22+
23+
on:
24+
pull_request:
25+
branches: [main, master]
26+
paths:
27+
- 'cmd/nftban-installer/**'
28+
- 'internal/installer/state/**'
29+
- '.github/workflows/ci-install-canonization.yml'
30+
push:
31+
branches: [main]
32+
workflow_dispatch:
33+
34+
concurrency:
35+
group: ci-install-canonization-${{ github.ref }}
36+
cancel-in-progress: true
37+
38+
permissions:
39+
contents: read
40+
41+
jobs:
42+
install-canonization:
43+
name: Install Canonization (${{ matrix.label }})
44+
strategy:
45+
fail-fast: false
46+
matrix:
47+
include:
48+
- label: ubuntu-24.04
49+
runner: ubuntu-24.04
50+
container: ''
51+
- label: almalinux-9
52+
runner: ubuntu-24.04
53+
container: almalinux:9
54+
runs-on: ${{ matrix.runner }}
55+
container: ${{ matrix.container }}
56+
steps:
57+
- name: Checkout
58+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
59+
60+
- name: Install system dependencies (DEB)
61+
if: matrix.label == 'ubuntu-24.04'
62+
run: |
63+
sudo apt-get update -qq
64+
sudo apt-get install -y nftables
65+
66+
- name: Install system dependencies (RPM)
67+
if: matrix.label == 'almalinux-9'
68+
run: |
69+
dnf -y install epel-release
70+
dnf -y install nftables git tar gzip procps-ng findutils sudo golang
71+
72+
- name: Set up Go (DEB only)
73+
if: matrix.label == 'ubuntu-24.04'
74+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
75+
with:
76+
go-version: '1.25'
77+
78+
- name: Build nftban-installer
79+
shell: bash
80+
run: |
81+
set -Eeuo pipefail
82+
mkdir -p bin
83+
go build -trimpath -o bin/nftban-installer ./cmd/nftban-installer/
84+
test -x bin/nftban-installer
85+
86+
- name: Prepare minimal host state
87+
shell: bash
88+
run: |
89+
set -Eeuo pipefail
90+
sudo install -d /var/lib/nftban/state
91+
92+
# ------------------------------------------------------------------
93+
# G3-IN-REFUSE-DRY-RUN — --mode=install --dry-run must refuse.
94+
# Pre-PR-22B behaviour was to silently execute all five install
95+
# phases, mutating the host. The fix refuses explicitly and exits
96+
# non-zero; the message must reference PR-22B so operators find
97+
# the context when grep'ing the error.
98+
# ------------------------------------------------------------------
99+
- name: G3-IN-REFUSE-DRY-RUN — install dry-run is refused
100+
shell: bash
101+
run: |
102+
set -Eeuo pipefail
103+
# Seed a history file so the audit can also assert no pollution
104+
# even if the binary accidentally executed any code path.
105+
sudo mkdir -p /var/lib/nftban
106+
echo '{"schema_version":"1.0","entries":[]}' | sudo tee /var/lib/nftban/update-history.json >/dev/null
107+
before_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
108+
109+
set +e
110+
out=$(sudo ./bin/nftban-installer --mode=install --dry-run \
111+
--state-dir=/var/lib/nftban/state 2>&1)
112+
rc=$?
113+
set -e
114+
echo "rc=$rc"
115+
echo "$out"
116+
117+
if [[ "$rc" -eq 0 ]]; then
118+
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: install dry-run exited 0 — it must REFUSE, not silently proceed"
119+
exit 1
120+
fi
121+
# Must contain an explicit refusal phrase so operators see why.
122+
if ! echo "$out" | grep -qE "install.*--dry-run is not implemented"; then
123+
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: output did not explain the refusal"
124+
exit 1
125+
fi
126+
# No history pollution from the refused run.
127+
after_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
128+
if [[ "$before_hist" != "$after_hist" ]]; then
129+
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: history file changed on a refused run"
130+
exit 1
131+
fi
132+
echo "G3-IN-REFUSE-DRY-RUN PASS — install dry-run refused cleanly, no pollution"
133+
134+
# ------------------------------------------------------------------
135+
# G3-IN-FLAG-COMBOS — reject operator-error flag combinations.
136+
# ------------------------------------------------------------------
137+
- name: G3-IN-FLAG-COMBOS — invalid flag combos refused
138+
shell: bash
139+
run: |
140+
set -Eeuo pipefail
141+
check_refuse() {
142+
local tag="$1"; shift
143+
set +e
144+
out=$(sudo ./bin/nftban-installer "$@" --state-dir=/var/lib/nftban/state 2>&1)
145+
rc=$?
146+
set -e
147+
if [[ "$rc" -eq 0 ]]; then
148+
echo "::error::G3-IN-FLAG-COMBOS FAIL [$tag]: should have refused, exited 0"
149+
echo "$out"
150+
return 1
151+
fi
152+
echo "[$tag] refused (rc=$rc)"
153+
}
154+
check_refuse "takeover+dryrun" --mode=upgrade --takeover --dry-run
155+
check_refuse "rpm+deb" --mode=upgrade --rpm --deb
156+
check_refuse "fdoc-without-purge" --mode=uninstall --force-delete-operator-config
157+
echo "G3-IN-FLAG-COMBOS PASS — invalid combinations rejected"
158+
159+
# ------------------------------------------------------------------
160+
# Go unit tests for install-scope behaviour: flag validation,
161+
# writeHistory gating, IsApplyTerminal allowlist, StateFile DryRun.
162+
# ------------------------------------------------------------------
163+
- name: Unit tests — install-scope repair (PR-22B)
164+
shell: bash
165+
run: |
166+
set -Eeuo pipefail
167+
go test -v ./internal/installer/state/... ./internal/installer/authority/... ./cmd/nftban-installer/...
168+
169+
summary:
170+
name: Install Canonization summary
171+
needs: install-canonization
172+
if: always()
173+
runs-on: ubuntu-latest
174+
steps:
175+
- name: Verdict
176+
run: |
177+
if [[ "${{ needs.install-canonization.result }}" != "success" ]]; then
178+
echo "::error::Install Canonization Gate FAILED — see matrix job output"
179+
exit 1
180+
fi
181+
echo "Install Canonization Gate PASSED on all matrix targets"

.github/workflows/ci-update-canonization.yml

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,21 @@ jobs:
151151
shell: bash
152152
run: |
153153
set -Eeuo pipefail
154-
# Snapshot state dir before the run.
155-
before=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s %T@\n' 2>/dev/null | sort)
154+
# PR-22B: snapshot ALL protected paths, including the
155+
# update-history.json truth surface. The previous version of
156+
# this gate whitelisted /var/lib/nftban/state/update_plan.json
157+
# and swallowed diff output behind "|| true" — exactly the
158+
# shape the audit flagged as "falsely reassuring CI."
159+
160+
# Seed a known update-history.json FIRST, BEFORE taking the
161+
# snapshot, so both before and after include the file. Without
162+
# the seed, an empty-to-empty diff hides history pollution.
163+
sudo mkdir -p /var/lib/nftban
164+
echo '{"schema_version":"1.0","entries":[]}' | sudo tee /var/lib/nftban/update-history.json >/dev/null
165+
166+
before=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
167+
before_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
168+
before_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")
156169
157170
set +e
158171
sudo ./bin/nftban-installer --mode=upgrade --dry-run \
@@ -171,14 +184,26 @@ jobs:
171184
# constraint verified in the output, not buried in source).
172185
grep -q "INV-U-001" /tmp/dryrun.out
173186
174-
# Snapshot again; the only acceptable mutation is the audit
175-
# write of update_plan.json under the state dir (documented).
176-
after=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s %T@\n' 2>/dev/null | sort)
187+
# PR-22B hard assertions — no soft "|| true". Any deviation is a
188+
# contract failure that must fail the gate. Size-only snapshot
189+
# (no mtime) so touch-style no-op reopens don't trigger.
190+
after=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
191+
after_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
192+
after_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")
177193
178-
# Accept: appearance of update_plan.json under state dir only.
179-
diff <(echo "$before") <(echo "$after") \
180-
| grep -v '/var/lib/nftban/state/update_plan.json' \
181-
| grep -v '^---' | grep -v '^[<>] *$' || true
194+
if [[ "$before_hist" != "$after_hist" ]]; then
195+
echo "::error::G3-U3 FAIL: update-history.json modified by dry-run (before=$before_hist after=$after_hist) — audit finding F regression"
196+
exit 1
197+
fi
198+
if [[ "$before_state" != "$after_state" ]]; then
199+
echo "::error::G3-U3 FAIL: install_state modified by dry-run (before=$before_state after=$after_state) — sf.DryRun must suppress Transition persistence"
200+
exit 1
201+
fi
202+
if [[ "$before" != "$after" ]]; then
203+
echo "::error::G3-U3 FAIL: filesystem under /var/lib/nftban or /etc/nftban changed during dry-run"
204+
diff <(echo "$before") <(echo "$after") || true
205+
exit 1
206+
fi
182207
183208
# Hard assertion: no file under /etc/nftban/** was modified.
184209
if ! diff -q <(echo "$before" | grep '^/etc/nftban/') \
@@ -191,7 +216,7 @@ jobs:
191216
# if preflight fails. Never 2/3/4 for a well-formed run on a
192217
# host that doesn't have a real daemon.
193218
case "$rc" in
194-
0|1) echo "G3-U3 PASS — exit=$rc (preflight result reflected correctly)" ;;
219+
0|1) echo "G3-U3 PASS — exit=$rc (preflight result reflected correctly); history + state + fs all unchanged" ;;
195220
*) echo "::error::G3-U3 FAIL: unexpected exit code $rc"; exit 1 ;;
196221
esac
197222
@@ -282,39 +307,58 @@ jobs:
282307
# for forbidden patterns before any runtime test. Fails fast if
283308
# apply ever gains a direct mutation surface.
284309
# ------------------------------------------------------------------
285-
- name: G3-U5..U10 — structural call-path audit of update_apply.go
310+
- name: G3-U5..U10 — structural call-path audit of update scope
286311
shell: bash
287312
run: |
288313
set -Eeuo pipefail
289-
src=cmd/nftban-installer/update_apply.go
290-
echo "Auditing $src for forbidden patterns..."
314+
# PR-22B: widen audit scope from update_apply.go only to the
315+
# full update reach set. The audit found dry-run writes that
316+
# the narrower scope missed (update_dryrun.go, phaseDetect
317+
# reuse in phases.go). Shared dispatchers in main.go are also
318+
# checked for direct writes, but are allowed to call the
319+
# history writer via the writeHistory helper (guarded by
320+
# IsApplyTerminal).
321+
srcs=(
322+
cmd/nftban-installer/update_apply.go
323+
cmd/nftban-installer/update_dryrun.go
324+
)
325+
echo "Auditing ${srcs[*]} for forbidden patterns..."
291326
fail=0
292-
for pat in \
293-
'exec\.Run\("nft"[^)]*add' \
294-
'exec\.Run\("nft"[^)]*flush' \
295-
'exec\.Run\("nft"[^)]*delete' \
296-
'exec\.Run\("systemctl"[^)]*stop' \
297-
'exec\.Run\("systemctl"[^)]*start' \
298-
'exec\.Run\("systemctl"[^)]*restart' \
299-
'exec\.Run\("systemctl"[^)]*reload' \
300-
'exec\.Run\("systemctl"[^)]*enable' \
301-
'exec\.Run\("systemctl"[^)]*disable' \
302-
'exec\.Run\("systemctl"[^)]*mask' \
303-
'exec\.Run\("systemctl"[^)]*unmask' \
304-
'exec\.Run\("ufw"' \
305-
'exec\.Run\("iptables"' \
306-
'exec\.Run\("apt-get"[^)]*remove' \
307-
'exec\.Run\("dnf"[^)]*remove' \
308-
'exec\.WriteFileAtomic\(.*"/etc/nftban/' \
309-
'exec\.WriteFileAtomic\(.*"/usr/lib/nftban/' \
310-
'\.conf\.local'; do
311-
if grep -nE "$pat" "$src" 2>/dev/null; then
312-
echo "::error::G3-U5..U10 FAIL: forbidden pattern '$pat' in $src"
313-
fail=1
314-
fi
327+
for src in "${srcs[@]}"; do
328+
for pat in \
329+
'exec\.Run\("nft"[^)]*add' \
330+
'exec\.Run\("nft"[^)]*flush' \
331+
'exec\.Run\("nft"[^)]*delete' \
332+
'exec\.Run\("nft"[^)]*create' \
333+
'exec\.Run\("systemctl"[^)]*stop' \
334+
'exec\.Run\("systemctl"[^)]*start' \
335+
'exec\.Run\("systemctl"[^)]*restart' \
336+
'exec\.Run\("systemctl"[^)]*reload' \
337+
'exec\.Run\("systemctl"[^)]*enable' \
338+
'exec\.Run\("systemctl"[^)]*disable' \
339+
'exec\.Run\("systemctl"[^)]*mask' \
340+
'exec\.Run\("systemctl"[^)]*unmask' \
341+
'exec\.Run\("ufw"' \
342+
'exec\.Run\("iptables"[^-]' \
343+
'exec\.Run\("apt-get"[^)]*remove' \
344+
'exec\.Run\("apt-get"[^)]*purge' \
345+
'exec\.Run\("dnf"[^)]*remove' \
346+
'exec\.Run\("dnf"[^)]*erase' \
347+
'exec\.WriteFileAtomic\(.*"/etc/nftban/' \
348+
'exec\.WriteFileAtomic\(.*"/usr/lib/nftban/' \
349+
'os\.WriteFile\(' \
350+
'os\.Create\(' \
351+
'os\.MkdirAll\(' \
352+
'os\.Rename\(' \
353+
'\.conf\.local'; do
354+
if grep -nE "$pat" "$src" 2>/dev/null; then
355+
echo "::error::G3-U5..U10 FAIL: forbidden pattern '$pat' in $src"
356+
fail=1
357+
fi
358+
done
315359
done
316360
if (( fail > 0 )); then
317-
echo "::error::Structural audit failed — update_apply.go contains forbidden call pattern"
361+
echo "::error::Structural audit failed — update scope contains forbidden call pattern"
318362
exit 1
319363
fi
320364
echo "G3-U5..U10 structural audit PASS"

0 commit comments

Comments
 (0)