PR26.5: source-install payload completeness (#532) #55
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 authorized mutations on the CSF restore path are: | |
| # | |
| # - exec.ServiceUnmask("csf.service") A.1 (typed; PR-26-code-B) | |
| # - exec.ServiceEnable("csf.service") A.2 | |
| # - exec.Rename("/usr/sbin/csf.disabled", | |
| # "/usr/sbin/csf") A.3 (typed; PR-26-code-B) | |
| # - exec.ServiceStart("csf.service") A.5 | |
| # - exec.ServiceStop("nftband.service") A.6 | |
| # - exec.NftDeleteTable("ip", "nftban") A.7 | |
| # - exec.NftDeleteTable("ip6", "nftban") A.7 | |
| # | |
| # PR-26-code-B (§43) promoted A.1 + A.3 from raw `Run("systemctl", | |
| # "unmask", …)` and `Run("mv", …)` to the typed methods listed | |
| # above. The gate's old `\bexec\.ServiceUnmask\(` forbid is | |
| # therefore REMOVED, and explicit raw-Run forbids replace it. | |
| # | |
| # The gate's job is forbidden-symbol coverage. 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, | |
| # TestCSFMutate_PR26B_A1_ServiceUnmaskOnlyCSFService, | |
| # TestCSFMutate_PR26B_A3_RenameOnlyCSFBinaryRestore). | |
| # | |
| # §46.1 line-skipping discipline: the gate scans only the | |
| # production file (no _test.go), and skips line-leading "//" | |
| # comments to avoid the false-positive class that hit Policy | |
| # Gates on PR #511. | |
| # ------------------------------------------------------------------ | |
| - 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 | |
| # §46.1 production-only scan: build a comment-stripped view | |
| # so doc-comment lines never trip the forbidden-pattern grep. | |
| stripped=$(grep -vE '^[[:space:]]*//' "$target" || true) | |
| # Forbidden: out-of-Amendment-1 mutation surface. Each pattern | |
| # is anchored to a call expression (\bexec\. or similar) so | |
| # legitimate string literals don't false-match. Doc comments | |
| # are already excluded by the line-leading // strip above. | |
| # | |
| # WriteFileAtomic is NOT globally forbidden here because | |
| # PR-26-code-C authorizes A.4 cron restore writes (only to | |
| # /etc/cron.d/csf-cron and /etc/cron.d/lfd-cron, gated by | |
| # the §42.2 manifest). Those writes are constrained by the | |
| # dedicated G4-RESTORE-CRON-MANIFEST-INTEGRITY gate below | |
| # (writer + reader symbol pin + cron-target literal allow- | |
| # list). Carving line-exceptions into this gate would recreate | |
| # the regex-brittleness class the auditor flagged at PR #515. | |
| forbidden_patterns=( | |
| # Service-policy mutations (mask/disable/daemon-reload) are | |
| # forbidden by Amendment 1 §34. ServiceUnmask is no longer | |
| # forbidden — PR-26-code-B promoted it to authorized typed | |
| # method for A.1. | |
| '\bexec\.ServiceMask\(' | |
| '\bexec\.ServiceDisable\(' | |
| '\bexec\.DaemonReload\(' | |
| # Direct OS bypass (must route through the executor). | |
| '"os/exec"' | |
| '\bexec\.Command\(' | |
| '\bos\.Rename\(' | |
| '\bos\.Remove[All]*\(' | |
| '\bos\.WriteFile\(' | |
| '\bos\.Create\(' | |
| '\bsyscall\.' | |
| # PR-26-code-B raw-Run policy tightening (§43.3): mutating | |
| # systemctl verbs MUST go through their typed methods. | |
| # Read-only `Run("systemctl", "is-enabled", …)` and similar | |
| # probes remain authorized. | |
| 'Run\("systemctl",[[:space:]]*"start"' | |
| 'Run\("systemctl",[[:space:]]*"stop"' | |
| 'Run\("systemctl",[[:space:]]*"enable"' | |
| 'Run\("systemctl",[[:space:]]*"disable"' | |
| 'Run\("systemctl",[[:space:]]*"mask"' | |
| 'Run\("systemctl",[[:space:]]*"unmask"' | |
| 'Run\("systemctl",[[:space:]]*"restart"' | |
| 'Run\("systemctl",[[:space:]]*"reload"' | |
| 'Run\("systemctl",[[:space:]]*"daemon-reload"' | |
| # Raw mv via Run is forbidden — typed exec.Rename is the | |
| # only authorized atomic-rename surface. | |
| 'Run\("mv"\b' | |
| # 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 echo "$stripped" | grep -nE "$pat" >/dev/null 2>&1; then | |
| # Re-grep against the original file (with line numbers) | |
| # so the error message points at the right line. | |
| echo "::error::G4-RESTORE-EXEC-NO-OUT-OF-TARGET: forbidden call expression matching '$pat' found in $target" | |
| grep -nE "$pat" "$target" || true | |
| 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 + Rename 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 < <(echo "$stripped" | grep -n 'NftDeleteTable(' || 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" | |
| # ------------------------------------------------------------------ | |
| # G4-RESTORE-CRON-MANIFEST-INTEGRITY (PR-26-code-C / §46) — | |
| # structural pin on the CSF/LFD cron-backup writer + reader. | |
| # | |
| # The §42.2 lock authorizes A.4 cron-restore ONLY when the | |
| # install-time writer recorded the file content with sha256 and | |
| # ONLY for the two locked source paths | |
| # (/etc/cron.d/csf-cron, /etc/cron.d/lfd-cron). This gate | |
| # structurally enforces: | |
| # | |
| # - the writer file (switchop/cron_manifest.go) declares the | |
| # manifest-dir constant, the schema-version constant, and | |
| # both source-path constants verbatim | |
| # - the writer file uses the shared sha256 helper symbol | |
| # ComputeCronBackupSHA256 to compute the manifest entry | |
| # - the reader file (cmd/nftban-installer/restore_deps_csf.go) | |
| # references both ReadCronBackupManifest and | |
| # VerifyCronBackupEntry — i.e. the integrity check is | |
| # consumed, not just imported | |
| # - neither file references DirectAdmin custombuild, | |
| # iptables-restore, or a broad /etc/cron.d/* glob | |
| # | |
| # Per §46.1 discipline: production-code-only, comment-stripped. | |
| # ------------------------------------------------------------------ | |
| - name: G4-RESTORE-CRON-MANIFEST-INTEGRITY — writer + reader structural pin | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| writer=internal/installer/switchop/cron_manifest.go | |
| reader=cmd/nftban-installer/restore_deps_csf.go | |
| for f in "$writer" "$reader"; do | |
| if [[ ! -f "$f" ]]; then | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: $f not found" | |
| exit 1 | |
| fi | |
| done | |
| # §46.1 production-only scan: strip line-leading // comments. | |
| writer_src=$(grep -vE '^[[:space:]]*//' "$writer" || true) | |
| reader_src=$(grep -vE '^[[:space:]]*//' "$reader" || true) | |
| fail=0 | |
| # ---- WRITER required symbols ------------------------------- | |
| # Each pattern is a structural element the writer MUST contain. | |
| # Whitespace-flexible matchers ([[:space:]]+) so the patterns | |
| # don't break when gofmt re-aligns the const block. | |
| writer_required=( | |
| 'CronManifestSchemaVersion[[:space:]]+=[[:space:]]+"1\.0\.0"' | |
| 'CronManifestDir[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/csf-cron-backup"' | |
| 'CronManifestFile[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/csf-cron-backup/manifest\.json"' | |
| 'CronCSFSrcPath[[:space:]]+=[[:space:]]+"/etc/cron\.d/csf-cron"' | |
| 'CronLFDSrcPath[[:space:]]+=[[:space:]]+"/etc/cron\.d/lfd-cron"' | |
| 'func ComputeCronBackupSHA256\(content \[\]byte\) string' | |
| 'func WriteCronBackupManifest\(' | |
| 'func ReadCronBackupManifest\(' | |
| 'func VerifyCronBackupEntry\(' | |
| 'sha256\.Sum256' | |
| ) | |
| for pat in "${writer_required[@]}"; do | |
| if ! echo "$writer_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: writer ($writer) missing required symbol matching '$pat'" | |
| fail=1 | |
| fi | |
| done | |
| # ---- READER required symbols ------------------------------- | |
| # The A.4 reader path MUST consume both the manifest reader | |
| # and the integrity-verifier — i.e. the sha256 check is | |
| # actually performed before A.4 acts. | |
| reader_required=( | |
| 'switchop\.ReadCronBackupManifest\(' | |
| 'switchop\.VerifyCronBackupEntry\(' | |
| 'ErrCSFRestoreCronManifestCorrupt' | |
| ) | |
| for pat in "${reader_required[@]}"; do | |
| if ! echo "$reader_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader ($reader) missing required symbol matching '$pat'" | |
| fail=1 | |
| fi | |
| done | |
| # ---- WRITER + READER forbidden symbols --------------------- | |
| # Defense-in-depth: even though the strengthened | |
| # G4-RESTORE-EXEC-NO-OUT-OF-TARGET already covers some of | |
| # these, restate the cron-specific bans. | |
| forbidden=( | |
| '\bcustombuild\b' | |
| 'iptables-restore' | |
| '"/etc/cron\.d/\*"' | |
| 'WriteFile.*"/etc/cron\.d/[^c]' | |
| ) | |
| for pat in "${forbidden[@]}"; do | |
| if echo "$writer_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: writer ($writer) contains forbidden pattern '$pat'" | |
| fail=1 | |
| fi | |
| if echo "$reader_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader ($reader) contains forbidden pattern '$pat'" | |
| fail=1 | |
| fi | |
| done | |
| # ---- Reader's authorized target-path allow-list ----------- | |
| # The A.4 reader's WriteFileAtomic call MUST target only one | |
| # of the two §42.2-locked paths. Structural check: every | |
| # WriteFileAtomic call in restore_deps_csf.go that targets a | |
| # /etc/cron.d/* path MUST use one of csfCronPath / lfdCronPath | |
| # as the first argument. We grep for any /etc/cron.d/ literal | |
| # in WriteFileAtomic args and require it equal one of the two | |
| # locked literals. Path constants live in restore_deps_csf.go | |
| # (csfCronPath = "/etc/cron.d/csf-cron"; | |
| # lfdCronPath = "/etc/cron.d/lfd-cron"), so the reader uses | |
| # the constants — string-literal grep should find zero | |
| # /etc/cron.d/ matches inside WriteFileAtomic argument lists. | |
| while IFS= read -r line; do | |
| # Capture WriteFileAtomic(... "literal" ...) pattern. | |
| literal=$(echo "$line" | grep -oE 'WriteFileAtomic\([^)]*"/etc/cron\.d/[^"]*"' | grep -oE '"/etc/cron\.d/[^"]*"' || true) | |
| if [[ -n "$literal" ]]; then | |
| case "$literal" in | |
| '"/etc/cron.d/csf-cron"'|'"/etc/cron.d/lfd-cron"') | |
| ;; | |
| *) | |
| echo "::error::G4-RESTORE-CRON-MANIFEST-INTEGRITY: reader writes unauthorized cron literal: $literal" | |
| fail=1 | |
| ;; | |
| esac | |
| fi | |
| done < <(echo "$reader_src" | grep -nE 'WriteFileAtomic\([^)]*"/etc/cron\.d/' || true) | |
| if [[ "$fail" -ne 0 ]]; then | |
| echo "::error::§42.2 / §46 violation — cron-backup manifest integrity not enforced." | |
| echo "::error::See internal/installer/restore/contract.md §42 + §46 for the lock." | |
| exit 1 | |
| fi | |
| echo "G4-RESTORE-CRON-MANIFEST-INTEGRITY PASS — writer + reader structurally consume the shared sha256 + manifest API" | |
| # ------------------------------------------------------------------ | |
| # G4-RESTORE-EVIDENCE-RECORD (PR-26-code-D / §46) — structural | |
| # pin on the post-restore evidence-record writer. | |
| # | |
| # The §39.3 + §48.6 lock requires that ALL evidence-record file | |
| # writes route through a SINGLE helper using a NAMED CONSTANT | |
| # for the destination directory. This gate structurally | |
| # enforces: | |
| # | |
| # - cmd/nftban-installer/restore_evidence.go declares the | |
| # restoreEvidenceDir constant verbatim and sets its value | |
| # to /var/lib/nftban/state/restore-evidence | |
| # - the file declares the writeRestoreEvidenceRecord helper | |
| # and the schema_version constant set to "1.0.0" | |
| # - the file contains exactly ONE WriteFileAtomic call (the | |
| # call inside the single helper) | |
| # - the file does NOT reference update-history.json, | |
| # uninstall.Probe, restore.Decide, restore.PlanFromDecision, | |
| # detect.DetectPanel, or any mutation primitive | |
| # (recording-only invariant per §39.3) | |
| # - the dispatcher (cmd/nftban-installer/restore_decide.go) | |
| # calls writeRestoreEvidenceRecord — i.e. evidence is | |
| # actually consumed, not just imported | |
| # | |
| # §46.1 discipline: production-code-only, comment-stripped. | |
| # ------------------------------------------------------------------ | |
| - name: G4-RESTORE-EVIDENCE-RECORD — single-helper structural pin | |
| shell: bash | |
| run: | | |
| set -Eeuo pipefail | |
| ev=cmd/nftban-installer/restore_evidence.go | |
| dispatcher=cmd/nftban-installer/restore_decide.go | |
| for f in "$ev" "$dispatcher"; do | |
| if [[ ! -f "$f" ]]; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $f not found" | |
| exit 1 | |
| fi | |
| done | |
| ev_src=$(grep -vE '^[[:space:]]*//' "$ev" || true) | |
| disp_src=$(grep -vE '^[[:space:]]*//' "$dispatcher" || true) | |
| fail=0 | |
| # ---- Required symbols in the evidence module --------------- | |
| ev_required=( | |
| 'restoreEvidenceDir[[:space:]]+=[[:space:]]+"/var/lib/nftban/state/restore-evidence"' | |
| 'restoreEvidenceSchemaVersion[[:space:]]+=[[:space:]]+"1\.0\.0"' | |
| 'restoreEvidenceFilenamePrefix[[:space:]]+=[[:space:]]+"restore-evidence-"' | |
| 'func writeRestoreEvidenceRecord\(' | |
| 'func buildRestoreEvidenceRecord\(' | |
| 'type RestoreEvidenceRecord struct' | |
| ) | |
| for pat in "${ev_required[@]}"; do | |
| if ! echo "$ev_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev missing required symbol matching '$pat'" | |
| fail=1 | |
| fi | |
| done | |
| # ---- Single-helper invariant ------------------------------- | |
| # Exactly one WriteFileAtomic call in the evidence module — | |
| # the one inside writeRestoreEvidenceRecord. Anything else | |
| # is a violation of the §46 single-helper lock. | |
| wfa_count=$(echo "$ev_src" | grep -cE '\bWriteFileAtomic\(' || true) | |
| if [[ "$wfa_count" -ne 1 ]]; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev contains $wfa_count WriteFileAtomic calls; want exactly 1 (single-helper invariant)" | |
| fail=1 | |
| fi | |
| # ---- Forbidden recording-only-violation symbols ----------- | |
| ev_forbidden=( | |
| 'restore\.Decide\(' | |
| 'restore\.PlanFromDecision\(' | |
| 'uninstall\.Probe\(' | |
| 'detect\.DetectPanel\(' | |
| 'writeHistory\(' | |
| 'update-history\.json' | |
| '\bexec\.ServiceStart\(' | |
| '\bexec\.ServiceStop\(' | |
| '\bexec\.ServiceEnable\(' | |
| '\bexec\.ServiceDisable\(' | |
| '\bexec\.ServiceMask\(' | |
| '\bexec\.ServiceUnmask\(' | |
| '\bexec\.NftDeleteTable\(' | |
| '\bexec\.NftAddElement\(' | |
| '\bexec\.DaemonReload\(' | |
| '\bexec\.Rename\(' | |
| '"os/exec"' | |
| '\bexec\.Command\(' | |
| '\bos\.Rename\(' | |
| '\bos\.Remove\(' | |
| '\bos\.WriteFile\(' | |
| '\bos\.Create\(' | |
| '\bsyscall\.' | |
| ) | |
| for pat in "${ev_forbidden[@]}"; do | |
| if echo "$ev_src" | grep -qE "$pat"; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $ev contains forbidden pattern '$pat' (recording-only invariant)" | |
| fail=1 | |
| fi | |
| done | |
| # ---- Dispatcher consumption pin ---------------------------- | |
| # The dispatcher MUST call writeRestoreEvidenceRecord — proves | |
| # evidence is consumed, not just imported. | |
| if ! echo "$disp_src" | grep -qE '\bwriteRestoreEvidenceRecord\('; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $dispatcher does not call writeRestoreEvidenceRecord" | |
| fail=1 | |
| fi | |
| if ! echo "$disp_src" | grep -qE '\bbuildRestoreEvidenceRecord\('; then | |
| echo "::error::G4-RESTORE-EVIDENCE-RECORD: $dispatcher does not call buildRestoreEvidenceRecord" | |
| fail=1 | |
| fi | |
| if [[ "$fail" -ne 0 ]]; then | |
| echo "::error::§39.3 / §46 / §48.6 violation — restore evidence-record writer not structurally enforced." | |
| exit 1 | |
| fi | |
| echo "G4-RESTORE-EVIDENCE-RECORD PASS — writer + dispatcher structurally consume the single evidence helper" | |
| 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" |