Skip to content

docs(v1.100 PR-26): record operator locks for code-A entry (#513) #23

docs(v1.100 PR-26): record operator locks for code-A entry (#513)

docs(v1.100 PR-26): record operator locks for code-A entry (#513) #23

# =============================================================================
# NFTBan — CI: Restore Canonization Gate (v1.100 PR-24 + PR-25)
# =============================================================================
# SPDX-License-Identifier: MPL-2.0
# Purpose: Enforce v1.100 PR-24 (decision) + PR-25 (execution + Amendment 1
# CSF restore mutation) scope locks.
#
# G4-RESTORE-DECISION-CORRECTNESS — fixture rule-path coverage
# (every rule declared in engine.go
# must have a fixture, every fixture
# must point to a declared rule)
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs spawn zero
# external processes and reach zero
# execution branches
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for kernel / service / filesystem
# mutation symbols
# G4-RESTORE-DETERMINISM — back-to-back fixture runs yield
# identical output, rule, and reason
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# cmd/nftban-installer/restore_deps_csf.go;
# only the §31 A.1-A.7 authorized
# operations are permitted
# (Amendment 1 §§30-36)
#
# Contracts:
# - internal/installer/restore/contract.md Part I (PR-24, merged 2026-04-20)
# - internal/installer/restore/contract.md Part II (PR-25, merged 2026-04-27)
# - internal/installer/restore/contract.md Part III (Amendment 1, merged 2026-04-28)
# =============================================================================
name: Restore Canonization Gate
on:
pull_request:
branches: [main, master]
paths:
- 'cmd/nftban-installer/**'
- 'internal/installer/restore/**'
- 'internal/installer/state/**'
- 'internal/installer/uninstall/**'
- '.github/workflows/ci-restore-canonization.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-restore-canonization-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
restore-canonization:
name: Restore 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 jq
- name: Install system dependencies (RPM)
if: matrix.label == 'almalinux-9'
run: |
dnf -y install epel-release
dnf -y install jq 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'
# ------------------------------------------------------------------
# G4-RESTORE-NO-IMPLICIT-EXEC — static scan of the restore package
# for any symbol that could cause kernel / service / filesystem
# mutation. Per contract seed §10, the restore package must contain
# zero such symbols. Fires BEFORE tests so a reviewer sees the
# scope violation immediately.
# ------------------------------------------------------------------
- name: G4-RESTORE-NO-IMPLICIT-EXEC — static scan of restore package
shell: bash
run: |
set -Eeuo pipefail
# Scope: restore package, non-test Go files only. Test files
# may reference these symbols as fixtures / negative-test
# coverage, which is legitimate.
files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
if [[ -z "$files" ]]; then
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: restore package files not found"
exit 1
fi
# Forbidden symbols per contract seed §10 "Forbidden surfaces"
# and §12 G4-RESTORE-NO-IMPLICIT-EXEC assertion.
forbidden_patterns=(
# Process spawn paths
'exec\.Run\('
'exec\.RunWithStderr\('
'exec\.CommandContext\b'
'"os/exec"'
# Direct mutation commands by name
'exec\.Run\("nft"'
'exec\.Run\("iptables"'
'exec\.Run\("ip6tables"'
'exec\.Run\("systemctl"'
'exec\.Run\("ufw"'
'exec\.Run\("firewall-cmd"'
# Service lifecycle helpers
'exec\.ServiceStop\('
'exec\.ServiceStart\('
'exec\.ServiceRestart\('
'exec\.ServiceEnable\('
'exec\.ServiceDisable\('
'exec\.ServiceMask\('
'exec\.ServiceUnmask\('
# Filesystem write APIs
'exec\.WriteFileAtomic\('
'os\.Create\('
'os\.WriteFile\('
'os\.OpenFile\('
'os\.Rename\('
'os\.Remove\('
'os\.RemoveAll\('
'os\.MkdirAll\('
'os\.Mkdir\('
'ioutil\.WriteFile\('
# Rebuild / restoration execution helpers
'rebuild\.Run\('
'rebuild\.Apply\('
'switchop\.'
'services\.Enable'
'services\.Disable'
'services\.Mask'
'services\.Unmask'
)
fail=0
for pat in "${forbidden_patterns[@]}"; do
if grep -nE "$pat" $files 2>/dev/null; then
echo "::error::G4-RESTORE-NO-IMPLICIT-EXEC: forbidden symbol '$pat' found in restore package"
fail=1
fi
done
if [[ "$fail" -ne 0 ]]; then
echo "::error::PR-24 scope violation — restore package must be pure decision; no kernel / service / filesystem mutation symbols permitted."
echo "::error::See internal/installer/restore/contract.md §10 for the full forbidden list."
exit 1
fi
echo "G4-RESTORE-NO-IMPLICIT-EXEC PASS — restore package is symbolically pure"
# ------------------------------------------------------------------
# G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage.
#
# The restore engine test TestRuleCoverage_EveryRuleExercised
# asserts: every Rule* constant declared in engine.go MUST be hit
# by at least one fixture. A new rule without a fixture, or a
# fixture referencing a non-declared rule, fails this test.
# ------------------------------------------------------------------
- name: G4-RESTORE-DECISION-CORRECTNESS — rule-path coverage
shell: bash
env:
TMPDIR: /var/tmp
run: |
set -Eeuo pipefail
mkdir -p /var/tmp
go test -v -count=1 -run 'TestDecide_FixtureMatrix|TestRuleCoverage_EveryRuleExercised|TestDecide_OutputClosedEnum|TestDecide_LockedAmendment|TestDecide_HardStopsDominateAnyFlag|TestDecide_OrphanPanelAutoRefused' ./internal/installer/restore/...
# ------------------------------------------------------------------
# G4-RESTORE-REFUSAL-INTEGRITY — non-PROCEED outputs reach no
# execution branch.
#
# Implementation: the Decide() function is pure (no executor
# argument). Any execution path would require the dispatcher to
# invoke additional code after the Decide call. This gate asserts
# structurally that the restore package has no executor dependency
# AND that the dispatcher does not call into mutation-capable
# packages. Combined with G4-RESTORE-NO-IMPLICIT-EXEC, this closes
# the refusal-integrity claim.
# ------------------------------------------------------------------
- name: G4-RESTORE-REFUSAL-INTEGRITY — no executor dependency in restore package
shell: bash
run: |
set -Eeuo pipefail
non_test_files=$(find internal/installer/restore -type f -name '*.go' 2>/dev/null | grep -vE '_test\.go$' || true)
if [[ -z "$non_test_files" ]]; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package files not found"
exit 1
fi
# The restore package must not import the executor package in
# non-test code. If it did, the engine could grow a side-effect
# path. Fixtures / tests may reference it for simulation
# purposes, so test files are excluded from this check.
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/executor"' $non_test_files 2>/dev/null; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore package imports executor — Decide must remain pure"
exit 1
fi
# The dispatcher (restore_decide.go) uses the executor to
# gather inputs (Classify / Probe / DetectPanel) — that is
# permitted. But after the Decide() call returns a non-PROCEED
# output, the dispatcher must not call any mutation helper.
# This check enforces structural layering: mutation helpers
# are imported only by uninstall_apply.go, never by
# restore_decide.go.
if grep -nE '"github.com/itcmsgr/nftban/internal/installer/switchop"|"github.com/itcmsgr/nftban/internal/installer/services"' cmd/nftban-installer/restore_decide.go 2>/dev/null; then
echo "::error::G4-RESTORE-REFUSAL-INTEGRITY: restore dispatcher imports mutation-capable package"
exit 1
fi
echo "G4-RESTORE-REFUSAL-INTEGRITY PASS — restore engine is pure + dispatcher is layering-clean"
# ------------------------------------------------------------------
# G4-RESTORE-DETERMINISM — same inputs → same outputs across runs.
# TestDecide_Determinism runs every fixture twice via reflect.DeepEqual.
# ------------------------------------------------------------------
- name: G4-RESTORE-DETERMINISM — repeated evaluation equality
shell: bash
env:
TMPDIR: /var/tmp
run: |
set -Eeuo pipefail
mkdir -p /var/tmp
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/...
# Run a second time from scratch and diff nothing — the
# test itself asserts determinism within one run; this
# outer loop asserts determinism ACROSS runs (no cached
# state, no env reliance).
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run1.log
go test -v -count=1 -run 'TestDecide_Determinism' ./internal/installer/restore/... 2>&1 | tee /tmp/run2.log
# Strip timing lines (ok line carries elapsed seconds) and diff.
grep -vE '^ok\s|^FAIL\s' /tmp/run1.log > /tmp/run1.norm
grep -vE '^ok\s|^FAIL\s' /tmp/run2.log > /tmp/run2.norm
if ! diff -q /tmp/run1.norm /tmp/run2.norm >/dev/null; then
echo "::error::G4-RESTORE-DETERMINISM: test output differs across runs"
diff /tmp/run1.norm /tmp/run2.norm || true
exit 1
fi
echo "G4-RESTORE-DETERMINISM PASS — identical across two independent runs"
# ------------------------------------------------------------------
# G4-RESTORE-EXEC-NO-OUT-OF-TARGET — closed mutation surface in
# the cmd/-side restore-execution code. Per Amendment 1 §31 / §32,
# the only authorized mutations on the CSF restore path are:
#
# - Run("systemctl", "unmask", "csf.service") A.1
# - ServiceEnable("csf.service") A.2
# - Run("mv", "/usr/sbin/csf.disabled",
# "/usr/sbin/csf") A.3
# - ServiceStart("csf.service") A.5
# - ServiceStop("nftband.service") A.6
# - NftDeleteTable("ip", "nftban") A.7
# - NftDeleteTable("ip6", "nftban") A.7
#
# The gate's job is forbidden-symbol coverage at the call-expression
# level. Per-call argument enforcement (which unit is started, which
# path is renamed) is delegated to the in-Go runtime tests in
# restore_deps_csf_test.go (TestCSFMutate_4B3csf_A1_NoUnmaskOfOtherServices,
# …_A2_EnableOnlyCSFService, …_A5_StartsOnlyCSFService,
# …_A6_StopsOnlyNftband_AfterCSFStarts,
# …_HappyPath_NoOutOfTargetMutation) which assert against
# MockExecutor with full Go-level type information. Trying to
# reproduce that in shell regex on identifier arguments
# (csfServiceUnit, oldpath/newpath) gives false confidence.
#
# Forbidden patterns are call-expression-anchored (\bexec\.) so
# they catch real call sites and skip prose / error messages /
# comments / const definitions.
# ------------------------------------------------------------------
- name: G4-RESTORE-EXEC-NO-OUT-OF-TARGET — restore_deps_csf.go closed mutation surface
shell: bash
run: |
set -Eeuo pipefail
target=cmd/nftban-installer/restore_deps_csf.go
if [[ ! -f "$target" ]]; then
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: $target not found"
exit 1
fi
# Forbidden: out-of-Amendment-1 mutation surface. Each pattern
# is anchored to a call expression (\bexec\. or similar) so
# legitimate doc strings, error messages, and const
# definitions don't false-match.
forbidden_patterns=(
# Service-policy mutations (mask/disable/unmask-typed/daemon-reload).
# The csf restore path uses Run("systemctl","unmask",csf.service)
# for A.1; the typed exec.ServiceUnmask method is forbidden so
# we don't accidentally land on a future executor surface that
# bypasses the audit trail.
'\bexec\.ServiceMask\('
'\bexec\.ServiceDisable\('
'\bexec\.ServiceUnmask\('
'\bexec\.DaemonReload\('
# Filesystem-mutation primitives — A.4 cron-restore is a
# soft-skip in 4B-3-csf; any WriteFileAtomic in this file is
# therefore an out-of-scope mutation.
'\bexec\.WriteFileAtomic\('
# Direct OS bypass (must route through the executor).
'"os/exec"'
'\bexec\.Command\('
'\bos\.Rename\('
'\bos\.Remove[All]*\('
'\bos\.WriteFile\('
'\bos\.Create\('
'\bsyscall\.'
# DirectAdmin custombuild rewrite forbidden (§34).
'\bcustombuild\b'
'"build set csf"'
# iptables / ip6tables manual rule restoration forbidden
# (§34: csf rebuilds its own iptables on start).
'iptables-restore'
'ip6tables-restore'
# Generic "fix everything" / "purge" / "rebuild" surface
# — Amendment 1 narrow scope forbids broad sweeps.
'\brebuild\.Run\('
'\brebuild\.Apply\('
'\bpurge\.'
'\bcleanup\.Apply\('
)
fail=0
for pat in "${forbidden_patterns[@]}"; do
if grep -nE "$pat" "$target" 2>/dev/null; then
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: forbidden call expression matching '$pat' found in $target"
fail=1
fi
done
# Allow-list pin: every NftDeleteTable call in $target MUST
# delete only ip:nftban or ip6:nftban — never any other table.
# NftDeleteTable's args in the production code ARE literal
# quoted strings (no constants), so this allow-list pin works
# cleanly without identifier resolution. Per-unit / per-path
# enforcement for systemctl + mv is delegated to the Go runtime
# tests against MockExecutor.
while IFS= read -r line; do
args=$(echo "$line" | grep -oE 'NftDeleteTable\([^)]*\)' || true)
if [[ -n "$args" ]]; then
case "$args" in
'NftDeleteTable("ip", "nftban")'|'NftDeleteTable("ip6", "nftban")')
;;
*)
echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: unauthorized NftDeleteTable call: $args"
fail=1
;;
esac
fi
done < <(grep -n 'NftDeleteTable(' "$target" || true)
if [[ "$fail" -ne 0 ]]; then
echo "::error::Amendment 1 §30 / §34 violation — restore_deps_csf.go must contain only the closed §31/§32 mutation surface."
echo "::error::See internal/installer/restore/contract.md §§30-36 for the full authorization."
echo "::error::Per-call argument enforcement is in restore_deps_csf_test.go runtime tests; this gate covers forbidden-symbol scope only."
exit 1
fi
echo "G4-RESTORE-EXEC-NO-OUT-OF-TARGET PASS — restore_deps_csf.go mutation surface is closed"
restore-canonization-summary:
name: Restore Canonization summary
runs-on: ubuntu-24.04
needs: [restore-canonization]
if: always()
steps:
- name: Summarize
run: |
if [[ "${{ needs.restore-canonization.result }}" != "success" ]]; then
echo "::error::Restore Canonization FAILED"
exit 1
fi
echo "Restore Canonization: all G4 gates passed across matrix"