R-861: narrow the agent's root grants — exact sudo patterns, felhom-priv-apply content checker, fixed hook/parent files in the bundle, signed self-update verified as root, escrow root reads pinned
gates / gates (push) Successful in 20s
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -2697,6 +2698,9 @@ func (e *escrowCeremonyErr) Error() string { return e.err.Error() }
|
||||
// restic password auto-attach), Create (R + self-verified blob), wipe the staged secret, upload
|
||||
// when asked. It PRINTS NOTHING — the output-mode shells own every byte of stdout/stderr. R is
|
||||
// returned for the caller to surface exactly once; escrow.Create never logs it and neither do we.
|
||||
// pbsStorageIDRe is a PVE storage id (letters, digits, '-', '_', '.'; starts with a letter) — never a path (R-861).
|
||||
var pbsStorageIDRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$`)
|
||||
|
||||
func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, opts escrowCeremonyOpts) (escrowCeremonyOutcome, *escrowCeremonyErr) {
|
||||
var out escrowCeremonyOutcome
|
||||
storage := opts.storage
|
||||
@@ -2706,6 +2710,16 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger,
|
||||
if storage == "" {
|
||||
return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create requires -storage <pbs-storage-id> (or escrow.pbs_storage_id)")}
|
||||
}
|
||||
// R-861 (v0.146.0): this runs as ROOT through FELHOM_ESCROW, but agent.json is owned by the agent user. So the
|
||||
// paths a root run reads never come from it: the PVE secret dir and the WireGuard state dir are the fixed defaults,
|
||||
// and the storage id is a plain PVE id (no slash, no dot-dot) — a crafted id or dir would read another root file.
|
||||
if os.Geteuid() == 0 {
|
||||
cfg.Backup.PBSSecretDir = ""
|
||||
cfg.WGTunnel.StateDir = ""
|
||||
}
|
||||
if !pbsStorageIDRe.MatchString(storage) {
|
||||
return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create: storage id %q is not a plain PVE storage id", storage)}
|
||||
}
|
||||
out.Storage = storage
|
||||
keyPath := cfg.Backup.PBSEncKeyPath(storage)
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||
)
|
||||
|
||||
// R-861 (agent v0.146.0): the root escrow ceremony builds a file path from the storage id; an id that is a path is
|
||||
// refused before anything is read. RED-PROOF: drop the pbsStorageIDRe check → the "../" ids reach the key stat and
|
||||
// come back as a "setup" error instead of "usage".
|
||||
func TestEscrowCeremony_StorageIDIsNeverAPath(t *testing.T) {
|
||||
lg := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, id := range []string{"../../../etc/shadow", "a/b", "/etc/pve/priv/x", ".hidden", ""} {
|
||||
cfg := config.Default()
|
||||
cfg.Escrow.PBSStorageID = "" // the flag decides here
|
||||
_, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: id})
|
||||
if e == nil || e.kind != "usage" {
|
||||
t.Errorf("storage id %q was not refused as usage (got %+v)", id, e)
|
||||
}
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.Backup.PBSSecretDir = t.TempDir()
|
||||
_, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: "felhom-pbs"})
|
||||
if e == nil || e.kind != "setup" {
|
||||
t.Fatalf("control: a plain id must pass the check and fail later on the missing key (setup), got %+v", e)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user