v1.100 PR-26-doc: restore verification / evidence hardening contract … #21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================= | |
| # 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" |