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
152 changes: 149 additions & 3 deletions .github/workflows/ci-restore-canonization.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,15 @@ jobs:
# 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
Expand All @@ -320,9 +329,6 @@ jobs:
'\bexec\.ServiceMask\('
'\bexec\.ServiceDisable\('
'\bexec\.DaemonReload\('
# Filesystem-mutation primitives. A.4 cron-restore is a
# soft-skip; any WriteFileAtomic here is out of scope.
'\bexec\.WriteFileAtomic\('
# Direct OS bypass (must route through the executor).
'"os/exec"'
'\bexec\.Command\('
Expand Down Expand Up @@ -403,6 +409,146 @@ jobs:

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"

restore-canonization-summary:
name: Restore Canonization summary
runs-on: ubuntu-24.04
Expand Down
148 changes: 136 additions & 12 deletions cmd/nftban-installer/restore_deps_csf.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,21 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"

"github.com/itcmsgr/nftban/internal/installer/detect"
"github.com/itcmsgr/nftban/internal/installer/executor"
"github.com/itcmsgr/nftban/internal/installer/switchop"
)

// fileModeFromUint32 converts a uint32 mode bitfield (as recorded in
// the cron-backup manifest) back into os.FileMode. Local helper —
// keeps the os import scoped to this single conversion. PR-26-code-C2.
func fileModeFromUint32(m uint32) os.FileMode {
return os.FileMode(m)
}

// =============================================================================
// Path / unit constants — every path the §31 A-table references is
// defined here. No string literals scattered through the function body.
Expand Down Expand Up @@ -132,6 +141,34 @@ var (
// ErrCSFRestoreServiceStopFailed wraps a non-nil ServiceStop error.
ErrCSFRestoreServiceStopFailed = errors.New("restore csf: A.6 ServiceStop(nftband.service) failed")

// ErrCSFRestoreCronManifestCorrupt is returned by A.4 as a HARD
// refusal when the §42.2 manifest is present but cannot be
// trusted: parse failure, schema mismatch, unknown-entry path,
// or per-entry sha256 mismatch. Per the auditor verdict on
// PR-26-code-C: when NFTBan has restore evidence on disk but
// cannot trust it, proceeding to A.5 (start csf.service) would
// weaken the evidence chain. A.4 stops before A.5, the safety
// net is retained by the existing Execute failure path, and the
// operator must inspect.
//
// Manifest ABSENT (no manifest.json at all) is the migration-gap
// case for pre-PR-26 hosts; that path remains a soft-skip and
// continues to A.5. Only manifest-present-but-untrusted is hard.
//
// PR-26-code-C2 addition (semantics revised on auditor pass —
// originally soft-skip, now hard-refusal).
ErrCSFRestoreCronManifestCorrupt = errors.New("restore csf: A.4 cron-backup manifest is corrupt or unrecognized — refusing before A.5 (operator must inspect)")

// ErrCSFRestoreCronTargetExists is returned by A.4 as a HARD
// refusal when a manifest entry's target /etc/cron.d/<name> is
// already present on disk at restore time. The operator may have
// re-created a different version of the cron file post-takeover;
// A.4 must NOT overwrite operator content. Per the auditor verdict
// this is treated as an evidence-conflict case — restoration is
// stopped before A.5 and the operator must reconcile manually.
// PR-26-code-C2 addition (semantics revised on auditor pass).
ErrCSFRestoreCronTargetExists = errors.New("restore csf: A.4 target /etc/cron.d/<name> already present — refusing before A.5 (operator-content collision; manual reconcile required)")

// ErrCSFRestoreNftReleaseUnsafe is returned by A.7 whenever the
// safety-net-safe predicate is either unavailable (nil — the
// 4B-3-csf default) or returns false / error. The host is left
Expand Down Expand Up @@ -305,19 +342,106 @@ func mutateToCSFTarget(ctx context.Context, m *productionMutationDep) error {
m.log.Info("restore csf: A.3 skip — %s present, no rename needed", csfBinary)
}

// A.4: Cron restore — soft-skipped in 4B-3-csf because §33 E.5
// (cron-backup manifest) is not produced by the current install
// path. The amendment authorizes A.4 only when E.5 holds; it does
// not, therefore A.4 is NOT executed. Operator-warning logged.
// A.4: Cron restore from manifest (PR-26-code-C2 / §42.2 lock,
// auditor-revised semantics — corrupt manifest is HARD refusal).
//
// When the installer-side prerequisite lands (separate amendment
// to switchop.disarmCSFArtifacts to write a backup), this branch
// flips from soft-skip to manifest-restore. csfCronPath and
// lfdCronPath constants are defined above so the change is
// localized.
if m.log != nil {
m.log.Warn("restore csf: A.4 soft-skip — cron-backup manifest (E.5) not produced by current install path; %s and %s NOT auto-restored. Operator must restore manually if needed.",
csfCronPath, lfdCronPath)
// Branches:
//
// - Manifest ABSENT (pre-PR-26 host): graceful soft-skip with
// an operator warning. A.4 does not act; control falls
// through to A.5. This is the migration-gap path.
// - Manifest present but UNTRUSTED (parse failure / schema
// mismatch / unknown-entry / per-entry sha256 mismatch):
// return ErrCSFRestoreCronManifestCorrupt — A.5 does NOT
// run, the existing §32 step-3 failure path retains the
// safety net, and the operator must inspect. Proceeding to
// start csf.service while restore evidence is on disk but
// untrusted would weaken the evidence chain (auditor
// verdict on PR-26-code-C).
// - Target ALREADY exists on disk (operator-content collision):
// return ErrCSFRestoreCronTargetExists. Same hard-refusal
// semantics as corrupt-manifest — A.4 must not overwrite
// operator content, and the surrounding evidence-conflict
// warrants stopping before A.5.
// - Manifest present + integrity-clean + targets absent: for
// each entry, restore the content via exec.WriteFileAtomic
// (preserves mode) + exec.Chown (preserves uid/gid). Then
// fall through to A.5.
//
// Absolutely no:
// - template regeneration (only restore-from-backup, never
// synthesize content)
// - writes outside the two §42.2-locked target paths
// - DirectAdmin custombuild rewrites
// - cron files that NFTBan did not back up itself
manifest, manifestPresent, manifestErr := switchop.ReadCronBackupManifest(m.exec, m.log)
switch {
case manifestErr != nil:
if m.log != nil {
m.log.Error("restore csf: A.4 manifest untrusted: %v — refusing before A.5", manifestErr)
}
return fmt.Errorf("%w: %v", ErrCSFRestoreCronManifestCorrupt, manifestErr)

case !manifestPresent:
if m.log != nil {
m.log.Warn("restore csf: A.4 soft-skip — cron-backup manifest absent (pre-PR-26 host); %s and %s NOT auto-restored",
csfCronPath, lfdCronPath)
}

default:
a4Restored := 0
for _, entry := range manifest.Files {
// Defense-in-depth: only the two §42.2-locked paths.
// (The reader already rejects unknown-entry manifests;
// this is belt-and-braces.)
if entry.Path != csfCronPath && entry.Path != lfdCronPath {
if m.log != nil {
m.log.Error("restore csf: A.4 manifest entry has unauthorized path %q — refusing before A.5", entry.Path)
}
return fmt.Errorf("%w: unauthorized entry path %q", ErrCSFRestoreCronManifestCorrupt, entry.Path)
}
// Verify sha256 integrity against the on-disk backup.
// Mismatch is HARD refusal — restore evidence on disk
// is untrusted; do not start csf with bad evidence.
content, vErr := switchop.VerifyCronBackupEntry(m.exec, entry)
if vErr != nil {
if m.log != nil {
m.log.Error("restore csf: A.4 sha256 verify failed for %s: %v — refusing before A.5", entry.Path, vErr)
}
return fmt.Errorf("%w: %v", ErrCSFRestoreCronManifestCorrupt, vErr)
}
// Operator-content collision: target already exists.
// HARD refuse — A.4 must not overwrite operator content
// and the conflict warrants stopping before A.5.
if m.exec.FileExists(entry.Path) {
if m.log != nil {
m.log.Error("restore csf: A.4 target %s already present — refusing before A.5 (operator-content collision)", entry.Path)
}
return fmt.Errorf("%w: %s", ErrCSFRestoreCronTargetExists, entry.Path)
}
// Restore: WriteFileAtomic preserves mode; Chown
// applies uid/gid for fidelity.
if err := m.exec.WriteFileAtomic(entry.Path, content, fileModeFromUint32(entry.Mode)); err != nil {
if m.log != nil {
m.log.Error("restore csf: A.4 WriteFileAtomic(%s) failed: %v — refusing before A.5", entry.Path, err)
}
return fmt.Errorf("%w: WriteFileAtomic(%s): %v", ErrCSFRestoreCronManifestCorrupt, entry.Path, err)
}
if err := m.exec.Chown(entry.Path, entry.UID, entry.GID); err != nil {
if m.log != nil {
m.log.Warn("restore csf: A.4 Chown(%s, %d, %d) failed: %v — content restored but ownership may be wrong",
entry.Path, entry.UID, entry.GID, err)
}
}
a4Restored++
if m.log != nil {
m.log.Info("restore csf: A.4 restored %s from manifest (sha256=%s, mode=%o, uid=%d, gid=%d)",
entry.Path, entry.SHA256, entry.Mode, entry.UID, entry.GID)
}
}
if m.log != nil {
m.log.Info("restore csf: A.4 manifest-restore complete (restored=%d)", a4Restored)
}
}

// =========================================================================
Expand Down
Loading
Loading