Skip to content

fix(v1.100 Amendment 2): code-C — preflight rejects mask-only unit-file (symlink to /dev/null)#521

Merged
itcmsgr merged 1 commit intomainfrom
amendment-2-code-C-preflight-unit-mask
Apr 28, 2026
Merged

fix(v1.100 Amendment 2): code-C — preflight rejects mask-only unit-file (symlink to /dev/null)#521
itcmsgr merged 1 commit intomainfrom
amendment-2-code-C-preflight-unit-mask

Conversation

@itcmsgr
Copy link
Copy Markdown
Owner

@itcmsgr itcmsgr commented Apr 28, 2026

Summary

Closes the third (and final, on the §32 path) implementation gap surfaced by the Amendment-2-code-E destructive-cycle audit chain.

Surfaced by: Amendment-2-code-E v2 corrected run on srv3 (2026-04-28T21:30:13Z). The dispatcher reached G1/AuthorityNFTBan/OrphanProceed PROCEED, code-B csf.disabled relaxation correctly accepted, §32 step 2 safety-net inserted, A.1 unmask succeeded — then A.2 ServiceEnable failed with Unit file csf.service does not exist because /etc/systemd/system/csf.service was a mask symlink to /dev/null with no real backing unit-file. Result: RESTORE_FAILED_EXECUTION at stage=mutate, exit=8, partial mutation (safety-net retained, csf still unmasked).

Like code-B, this is implementation-class — no contract amendment, no decision-lattice change, no §32 ordering change.

Surgical patch

In productionPreflightDep.PreflightTarget's unit-file OR-list loop:

  1. After exec.FileExists returns true for a candidate path, route through exec.Run("readlink", "-f", <path>) to obtain the resolved canonical path (read-only per §43.3 raw-Run policy).
  2. If resolved == "/dev/null" → treat the candidate as not-found, continue the OR-list, record maskOnlyObserved.
  3. Real file or symlink to real file → accept.
  4. If every candidate is absent → preserve existing ErrPreflightUnitMissing semantic.
  5. If at least one candidate was mask-only and no real backing path resolved → refuse with new typed sentinel ErrPreflightUnitFileMaskingOnly.

Same hardening applies uniformly to ufw / firewalld / iptables / csf — every firewall in §18.2.

Tests added (10 new test functions)

Test Scenario Expected
AMD2C-1 regular file at /etc/systemd/system/csf.service PASS
AMD2C-2 real unit at /usr/lib/systemd/system/csf.service PASS
AMD2C-3 mask symlink only REFUSE ErrPreflightUnitFileMaskingOnly
AMD2C-4 mask at /etc/ AND real at /usr/lib/ PASS (OR-list)
AMD2C-5 plain regular file PASS
AMD2C-6 symlink to real backing file PASS
AMD2C-7 ufw / firewalld / iptables mask hardening (3 sub-tests) REFUSE
AMD2C-8 unknown firewallType REFUSE ErrPreflightUnknownFirewall
AMD2C-9 NoMutationCalls — readlink only, zero mutation primitives enforced
AMD2C-10 no candidate path at all REFUSE ErrPreflightUnitMissing (NOT MaskingOnly)

4B-1.7 NoMutationCalls updated: allows readlink Run calls (read-only per §43.3) while still asserting ZERO mutation primitives.

Test results (lab4 build host)

  • go test ./cmd/nftban-installer/... -run 'Preflight|RestoreDeps'PASS
  • go test ./cmd/nftban-installer/...PASS
  • go test ./internal/installer/restore/...PASS
  • go test ./...64 packages PASS, 0 FAIL

Files changed

File Change
cmd/nftban-installer/restore_deps.go +61 / -8 (mask-detection branch + new sentinel + strings import)
cmd/nftban-installer/restore_deps_test.go +252 / -4 (10 new tests + 1 update)

Files NOT touched

  • internal/installer/restore/contract.md
  • internal/installer/restore/engine.go
  • internal/installer/restore/execute.go
  • internal/installer/state/machine.go
  • cmd/nftban-installer/flags.go
  • cmd/nftban-installer/main.go
  • .github/workflows/*

Invariants preserved

  • §23.1 preflight read-only.
  • INV-PR26-NEW-MUTATION-SURFACES-BOUNDED.
  • §32 ordering: UNTOUCHED — A.1/A.2 unchanged; mask detection happens at preflight before §32 step 2.
  • §22 / §19.4 terminals + exit codes: UNTOUCHED.
  • Amendment 1 §30.2 CSF-only mutation scope.
  • §19.2 layer 4 / main.go:132 history gate.
  • Code-B csf.disabled acceptance: PRESERVED — §54 binary relaxation still fires before unit-file check.

Out of scope

  • §59 Q3/Q4 typed Readlink/Lstat executor surfaces — using raw Run("readlink",-f,...) per §43.3.
  • Decision-layer §54 evidence E.6 mask-only detection — auditor noted this as optional follow-up; code-C closes the immediate gap at preflight-layer.

After merge

Per auditor disposition: srv2 selected as final destructive-cycle host. Operator stages srv2 through canonical NFTBan switchover (no manual mutation), fresh Tier 1 build on lab4, fresh signoff, fresh GO, one final restore run.

🤖 Generated with Claude Code

…le (symlink to /dev/null)

Surfaced by Amendment-2-code-E v2 corrected run on srv3 (2026-04-28T21:30:13Z):
the dispatcher reached G1/AuthorityNFTBan/OrphanProceed PROCEED, code-B
csf.disabled relaxation correctly accepted, §32 step 2 safety-net
inserted, A.1 unmask succeeded — then A.2 ServiceEnable failed with
"Unit file csf.service does not exist" because the canonical unit-file
path (/etc/systemd/system/csf.service) was a mask symlink to /dev/null
with NO real backing unit-file anywhere on the host. Result:
RESTORE_FAILED_EXECUTION at stage=mutate, exit=8, partial mutation
(safety-net retained, csf still unmasked).

This is the third (and final, on the §32 path) implementation gap
surfaced by the destructive-cycle audit chain. Like code-B, the fix is
implementation-class — no contract amendment, no decision-lattice
change, no §32 ordering change.

Surgical patch
==============

In productionPreflightDep.PreflightTarget's unit-file OR-list loop:
- After exec.FileExists returns true for a candidate path, route
  through exec.Run("readlink", "-f", <path>) to obtain the resolved
  canonical path (read-only per §43.3 raw-Run policy for read-only
  probes).
- If resolved == "/dev/null", treat the candidate as not-found and
  continue the OR-list. Records a maskOnlyObserved flag.
- A real file (resolved == path) or symlink to a real file
  (resolved != /dev/null) is accepted.
- If every candidate is absent: preserve existing
  ErrPreflightUnitMissing semantic.
- If at least one candidate was a mask-only symlink and no real
  backing path resolved: refuse with new typed sentinel
  ErrPreflightUnitFileMaskingOnly.

The new sentinel:
  ErrPreflightUnitFileMaskingOnly = errors.New(
    "restore preflight: unit file is a mask symlink (points to
    /dev/null); no real unit file backs the service")

Existing exit code remains 8. Existing terminal remains
RESTORE_FAILED_EXECUTION. Same hardening applies uniformly to ufw /
firewalld / iptables / csf — every firewall in §18.2.

Tests added (10 new test functions)
===================================

AMD2C-1: regular file at /etc/systemd/system/csf.service → PASS
AMD2C-2: real unit at /usr/lib/systemd/system/csf.service → PASS
AMD2C-3: mask symlink only → REFUSE ErrPreflightUnitFileMaskingOnly
AMD2C-4: mask at /etc/ AND real at /usr/lib/ → PASS (OR-list resolves)
AMD2C-5: plain regular file → PASS
AMD2C-6: symlink to real backing file → PASS
AMD2C-7: ufw / firewalld / iptables mask hardening (3 sub-tests)
AMD2C-8: unknown firewallType → REFUSE ErrPreflightUnknownFirewall
AMD2C-9: NoMutationCalls — readlink only, zero mutation primitives
AMD2C-10: no candidate path at all → REFUSE ErrPreflightUnitMissing
         (distinguished from MaskingOnly)

4B-1.7 NoMutationCalls test updated: now allows `readlink` Run calls
(read-only per §43.3) while still asserting ZERO mutation primitives.

Test results (lab4 build host)
==============================

- go test ./cmd/nftban-installer/... -run 'Preflight|RestoreDeps' → PASS
- go test ./cmd/nftban-installer/...                              → PASS
- go test ./internal/installer/restore/...                        → PASS
- go test ./...                                                    → 64 pkgs PASS, 0 FAIL

Files changed
=============

cmd/nftban-installer/restore_deps.go      | +61 / -8  (53 line net)
cmd/nftban-installer/restore_deps_test.go | +252 / -4 (10 new tests + 1 update)

No files outside the operator-allowed two.

Invariants preserved
====================

- §23.1 preflight read-only: PRESERVED — readlink is read-only per §43.3.
- INV-PR26-NEW-MUTATION-SURFACES-BOUNDED: PRESERVED — zero new surfaces.
- §32 ordering: UNTOUCHED — A.1/A.2 not modified; mask detection happens at preflight before §32 step 2.
- §22 / §19.4 terminals + exit codes: UNTOUCHED — same exit=8, same RESTORE_FAILED_EXECUTION.
- Amendment 1 §30.2 CSF-only mutation scope: PRESERVED — non-CSF firewalls get the same mask-symlink hardening but still strict no-.disabled-relaxation.
- §19.2 layer 4 / main.go:132 history gate: UNTOUCHED.
- Existing 4B-1 fixture matrix: behavior unchanged on real-file paths.
- Code-B csf.disabled acceptance: PRESERVED — §54 binary relaxation still fires before unit-file check.

No CI workflow edit. No state machine change. No flag change. No
contract change. No execute.go change. No engine.go change. No
main.go change.

No host action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@itcmsgr itcmsgr merged commit 786a116 into main Apr 28, 2026
63 checks passed
@itcmsgr itcmsgr deleted the amendment-2-code-C-preflight-unit-mask branch April 28, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant