Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/ci-install-canonization.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# =============================================================================
# NFTBan — CI: Install Canonization Gate (v1.100 PR-22B slice)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
#
# Purpose:
# G3-IN-REFUSE-DRY-RUN — `--mode=install --dry-run` must REFUSE with a
# clear error and a non-zero exit code. PR-22B
# does not add install preview capability; it
# removes false dry-run semantics by refusing
# unsupported install dry-run invocations.
#
# G3-IN-FLAG-COMBOS — operator-error flag combinations are rejected
# up front rather than silently accepted.
#
# Audit basis: extended audit §4.A ("install dry-run is dishonest today")
# and §4.H ("CLI flag semantics").
#
# =============================================================================

name: Install Canonization Gate

on:
pull_request:
branches: [main, master]
paths:
- 'cmd/nftban-installer/**'
- 'internal/installer/state/**'
- '.github/workflows/ci-install-canonization.yml'
push:
branches: [main]
workflow_dispatch:

concurrency:
group: ci-install-canonization-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
install-canonization:
name: Install Canonization (${{ matrix.label }})
strategy:
fail-fast: false
matrix:
include:
- label: ubuntu-24.04
runner: ubuntu-24.04
container: ''
- label: almalinux-9
runner: ubuntu-24.04
container: almalinux:9
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Install system dependencies (DEB)
if: matrix.label == 'ubuntu-24.04'
run: |
sudo apt-get update -qq
sudo apt-get install -y nftables

- name: Install system dependencies (RPM)
if: matrix.label == 'almalinux-9'
run: |
dnf -y install epel-release
dnf -y install nftables git tar gzip procps-ng findutils sudo golang

- name: Set up Go (DEB only)
if: matrix.label == 'ubuntu-24.04'
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.6.0
with:
go-version: '1.25'

- name: Build nftban-installer
shell: bash
run: |
set -Eeuo pipefail
mkdir -p bin
go build -trimpath -o bin/nftban-installer ./cmd/nftban-installer/
test -x bin/nftban-installer

- name: Prepare minimal host state
shell: bash
run: |
set -Eeuo pipefail
sudo install -d /var/lib/nftban/state

# ------------------------------------------------------------------
# G3-IN-REFUSE-DRY-RUN — --mode=install --dry-run must refuse.
# Pre-PR-22B behaviour was to silently execute all five install
# phases, mutating the host. The fix refuses explicitly and exits
# non-zero; the message must reference PR-22B so operators find
# the context when grep'ing the error.
# ------------------------------------------------------------------
- name: G3-IN-REFUSE-DRY-RUN — install dry-run is refused
shell: bash
run: |
set -Eeuo pipefail
# Seed a history file so the audit can also assert no pollution
# even if the binary accidentally executed any code path.
sudo mkdir -p /var/lib/nftban
echo '{"schema_version":"1.0","entries":[]}' | sudo tee /var/lib/nftban/update-history.json >/dev/null
before_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')

set +e
out=$(sudo ./bin/nftban-installer --mode=install --dry-run \
--state-dir=/var/lib/nftban/state 2>&1)
rc=$?
set -e
echo "rc=$rc"
echo "$out"

if [[ "$rc" -eq 0 ]]; then
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: install dry-run exited 0 — it must REFUSE, not silently proceed"
exit 1
fi
# Must contain an explicit refusal phrase so operators see why.
if ! echo "$out" | grep -qE "install.*--dry-run is not implemented"; then
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: output did not explain the refusal"
exit 1
fi
# No history pollution from the refused run.
after_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
if [[ "$before_hist" != "$after_hist" ]]; then
echo "::error::G3-IN-REFUSE-DRY-RUN FAIL: history file changed on a refused run"
exit 1
fi
echo "G3-IN-REFUSE-DRY-RUN PASS — install dry-run refused cleanly, no pollution"

# ------------------------------------------------------------------
# G3-IN-FLAG-COMBOS — reject operator-error flag combinations.
# ------------------------------------------------------------------
- name: G3-IN-FLAG-COMBOS — invalid flag combos refused
shell: bash
run: |
set -Eeuo pipefail
check_refuse() {
local tag="$1"; shift
set +e
out=$(sudo ./bin/nftban-installer "$@" --state-dir=/var/lib/nftban/state 2>&1)
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "::error::G3-IN-FLAG-COMBOS FAIL [$tag]: should have refused, exited 0"
echo "$out"
return 1
fi
echo "[$tag] refused (rc=$rc)"
}
check_refuse "takeover+dryrun" --mode=upgrade --takeover --dry-run
check_refuse "rpm+deb" --mode=upgrade --rpm --deb
check_refuse "fdoc-without-purge" --mode=uninstall --force-delete-operator-config
echo "G3-IN-FLAG-COMBOS PASS — invalid combinations rejected"

# ------------------------------------------------------------------
# Go unit tests for install-scope behaviour: flag validation,
# writeHistory gating, IsApplyTerminal allowlist, StateFile DryRun.
# ------------------------------------------------------------------
- name: Unit tests — install-scope repair (PR-22B)
shell: bash
run: |
set -Eeuo pipefail
go test -v ./internal/installer/state/... ./internal/installer/authority/... ./cmd/nftban-installer/...

summary:
name: Install Canonization summary
needs: install-canonization
if: always()
runs-on: ubuntu-latest
steps:
- name: Verdict
run: |
if [[ "${{ needs.install-canonization.result }}" != "success" ]]; then
echo "::error::Install Canonization Gate FAILED — see matrix job output"
exit 1
fi
echo "Install Canonization Gate PASSED on all matrix targets"
118 changes: 81 additions & 37 deletions .github/workflows/ci-update-canonization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,21 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
# Snapshot state dir before the run.
before=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s %T@\n' 2>/dev/null | sort)
# PR-22B: snapshot ALL protected paths, including the
# update-history.json truth surface. The previous version of
# this gate whitelisted /var/lib/nftban/state/update_plan.json
# and swallowed diff output behind "|| true" — exactly the
# shape the audit flagged as "falsely reassuring CI."

# Seed a known update-history.json FIRST, BEFORE taking the
# snapshot, so both before and after include the file. Without
# the seed, an empty-to-empty diff hides history pollution.
sudo mkdir -p /var/lib/nftban
echo '{"schema_version":"1.0","entries":[]}' | sudo tee /var/lib/nftban/update-history.json >/dev/null

before=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
before_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
before_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")

set +e
sudo ./bin/nftban-installer --mode=upgrade --dry-run \
Expand All @@ -171,14 +184,26 @@ jobs:
# constraint verified in the output, not buried in source).
grep -q "INV-U-001" /tmp/dryrun.out

# Snapshot again; the only acceptable mutation is the audit
# write of update_plan.json under the state dir (documented).
after=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s %T@\n' 2>/dev/null | sort)
# PR-22B hard assertions — no soft "|| true". Any deviation is a
# contract failure that must fail the gate. Size-only snapshot
# (no mtime) so touch-style no-op reopens don't trigger.
after=$(find /var/lib/nftban /etc/nftban -type f -printf '%p %s\n' 2>/dev/null | sort)
after_hist=$(sudo sha256sum /var/lib/nftban/update-history.json | awk '{print $1}')
after_state=$(sudo test -f /var/lib/nftban/state/install_state && sudo sha256sum /var/lib/nftban/state/install_state | awk '{print $1}' || echo "missing")

# Accept: appearance of update_plan.json under state dir only.
diff <(echo "$before") <(echo "$after") \
| grep -v '/var/lib/nftban/state/update_plan.json' \
| grep -v '^---' | grep -v '^[<>] *$' || true
if [[ "$before_hist" != "$after_hist" ]]; then
echo "::error::G3-U3 FAIL: update-history.json modified by dry-run (before=$before_hist after=$after_hist) — audit finding F regression"
exit 1
fi
if [[ "$before_state" != "$after_state" ]]; then
echo "::error::G3-U3 FAIL: install_state modified by dry-run (before=$before_state after=$after_state) — sf.DryRun must suppress Transition persistence"
exit 1
fi
if [[ "$before" != "$after" ]]; then
echo "::error::G3-U3 FAIL: filesystem under /var/lib/nftban or /etc/nftban changed during dry-run"
diff <(echo "$before") <(echo "$after") || true
exit 1
fi

# Hard assertion: no file under /etc/nftban/** was modified.
if ! diff -q <(echo "$before" | grep '^/etc/nftban/') \
Expand All @@ -191,7 +216,7 @@ jobs:
# if preflight fails. Never 2/3/4 for a well-formed run on a
# host that doesn't have a real daemon.
case "$rc" in
0|1) echo "G3-U3 PASS — exit=$rc (preflight result reflected correctly)" ;;
0|1) echo "G3-U3 PASS — exit=$rc (preflight result reflected correctly); history + state + fs all unchanged" ;;
*) echo "::error::G3-U3 FAIL: unexpected exit code $rc"; exit 1 ;;
esac

Expand Down Expand Up @@ -282,39 +307,58 @@ jobs:
# for forbidden patterns before any runtime test. Fails fast if
# apply ever gains a direct mutation surface.
# ------------------------------------------------------------------
- name: G3-U5..U10 — structural call-path audit of update_apply.go
- name: G3-U5..U10 — structural call-path audit of update scope
shell: bash
run: |
set -Eeuo pipefail
src=cmd/nftban-installer/update_apply.go
echo "Auditing $src for forbidden patterns..."
# PR-22B: widen audit scope from update_apply.go only to the
# full update reach set. The audit found dry-run writes that
# the narrower scope missed (update_dryrun.go, phaseDetect
# reuse in phases.go). Shared dispatchers in main.go are also
# checked for direct writes, but are allowed to call the
# history writer via the writeHistory helper (guarded by
# IsApplyTerminal).
srcs=(
cmd/nftban-installer/update_apply.go
cmd/nftban-installer/update_dryrun.go
)
echo "Auditing ${srcs[*]} for forbidden patterns..."
fail=0
for pat in \
'exec\.Run\("nft"[^)]*add' \
'exec\.Run\("nft"[^)]*flush' \
'exec\.Run\("nft"[^)]*delete' \
'exec\.Run\("systemctl"[^)]*stop' \
'exec\.Run\("systemctl"[^)]*start' \
'exec\.Run\("systemctl"[^)]*restart' \
'exec\.Run\("systemctl"[^)]*reload' \
'exec\.Run\("systemctl"[^)]*enable' \
'exec\.Run\("systemctl"[^)]*disable' \
'exec\.Run\("systemctl"[^)]*mask' \
'exec\.Run\("systemctl"[^)]*unmask' \
'exec\.Run\("ufw"' \
'exec\.Run\("iptables"' \
'exec\.Run\("apt-get"[^)]*remove' \
'exec\.Run\("dnf"[^)]*remove' \
'exec\.WriteFileAtomic\(.*"/etc/nftban/' \
'exec\.WriteFileAtomic\(.*"/usr/lib/nftban/' \
'\.conf\.local'; do
if grep -nE "$pat" "$src" 2>/dev/null; then
echo "::error::G3-U5..U10 FAIL: forbidden pattern '$pat' in $src"
fail=1
fi
for src in "${srcs[@]}"; do
for pat in \
'exec\.Run\("nft"[^)]*add' \
'exec\.Run\("nft"[^)]*flush' \
'exec\.Run\("nft"[^)]*delete' \
'exec\.Run\("nft"[^)]*create' \
'exec\.Run\("systemctl"[^)]*stop' \
'exec\.Run\("systemctl"[^)]*start' \
'exec\.Run\("systemctl"[^)]*restart' \
'exec\.Run\("systemctl"[^)]*reload' \
'exec\.Run\("systemctl"[^)]*enable' \
'exec\.Run\("systemctl"[^)]*disable' \
'exec\.Run\("systemctl"[^)]*mask' \
'exec\.Run\("systemctl"[^)]*unmask' \
'exec\.Run\("ufw"' \
'exec\.Run\("iptables"[^-]' \
'exec\.Run\("apt-get"[^)]*remove' \
'exec\.Run\("apt-get"[^)]*purge' \
'exec\.Run\("dnf"[^)]*remove' \
'exec\.Run\("dnf"[^)]*erase' \
'exec\.WriteFileAtomic\(.*"/etc/nftban/' \
'exec\.WriteFileAtomic\(.*"/usr/lib/nftban/' \
'os\.WriteFile\(' \
'os\.Create\(' \
'os\.MkdirAll\(' \
'os\.Rename\(' \
'\.conf\.local'; do
if grep -nE "$pat" "$src" 2>/dev/null; then
echo "::error::G3-U5..U10 FAIL: forbidden pattern '$pat' in $src"
fail=1
fi
done
done
if (( fail > 0 )); then
echo "::error::Structural audit failed — update_apply.go contains forbidden call pattern"
echo "::error::Structural audit failed — update scope contains forbidden call pattern"
exit 1
fi
echo "G3-U5..U10 structural audit PASS"
Expand Down
Loading
Loading