v0.61.0: audit fixes B1 (random temp staging) + D1 (mkfs wrapper member/RO re-checks) + D2 (empty-lsblk fail-safe) + D3 (blank-format anti-retarget)
From AUDIT-blast-radius-hostroot-localapi-2026-07-02.md. Each fix ships with a non-hollow test + a companion red-proof (shown failing on the pre-fix impl). Sudoers install-source grants became globs — deploy the sudoers drop-in with the binary. A1 (stale-lock pool-membership) deliberately excluded (spike). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -36,12 +36,23 @@ exec ` + AgentBin + ` guest-hook "$1" "$2"
|
||||
// InstallSnippet writes the pre-start hook wrapper into the PVE snippets dir (idempotent, root-owned,
|
||||
// executable). The agent runs as a non-root service user, so it writes an agent-writable temp file then
|
||||
// `install`s it host-root (same pattern as the bootstrap mount + dnsmasq drop-ins). Safe to call repeatedly.
|
||||
// The temp file is a RANDOM-named os.CreateTemp (audit B1): a fixed, predictable /tmp name could be
|
||||
// pre-created by another local user and rewritten between our write and root's install (TOCTOU into a
|
||||
// root-executed hookscript). The final mode comes from `install -m`, so the 0600 temp is fine.
|
||||
func InstallSnippet(ctx context.Context, runner proxmox.Runner) error {
|
||||
tmp := filepath.Join(os.TempDir(), "felhom-guest-hook.sh")
|
||||
if err := os.WriteFile(tmp, []byte(snippetBody), 0o755); err != nil {
|
||||
f, err := os.CreateTemp("", "felhom-guest-hook-*.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: create temp snippet: %w", err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := f.WriteString(snippetBody); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("guesthook: write temp snippet: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("guesthook: close temp snippet: %w", err)
|
||||
}
|
||||
if _, stderr, err := runner.Run(ctx, "install", "-m", "0755", "--", tmp, SnippetPath); err != nil {
|
||||
return fmt.Errorf("guesthook: install snippet to %s: %w: %s", SnippetPath, err, string(stderr))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package guesthook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recordingRunner is a fake proxmox.Runner that records every call and snapshots the content of the
|
||||
// install SOURCE file at call time (the deferred os.Remove would erase it before the test can look).
|
||||
type recordingRunner struct {
|
||||
calls [][]string
|
||||
srcContent []string
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if name == "install" && len(args) > 0 {
|
||||
src := args[len(args)-2]
|
||||
b, _ := os.ReadFile(src)
|
||||
r.srcContent = append(r.srcContent, string(b))
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// TestInstallSnippet_RandomTempName is the audit-B1 negative test: the staged install SOURCE must be a
|
||||
// RANDOM os.CreateTemp name (felhom-guest-hook-<random>.sh), never the fixed, pre-creatable
|
||||
// /tmp/felhom-guest-hook.sh (a local TOCTOU into a root-executed hookscript), and two consecutive
|
||||
// installs must stage through DIFFERENT paths.
|
||||
func TestInstallSnippet_RandomTempName(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #1: %v", err)
|
||||
}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #2: %v", err)
|
||||
}
|
||||
if len(r.calls) != 2 {
|
||||
t.Fatalf("expected 2 install calls, got %d: %v", len(r.calls), r.calls)
|
||||
}
|
||||
|
||||
randomName := regexp.MustCompile(`felhom-guest-hook-[^/\\]+\.sh$`)
|
||||
fixedName := regexp.MustCompile(`felhom-guest-hook\.sh$`)
|
||||
var srcs []string
|
||||
for i, call := range r.calls {
|
||||
// install -m 0755 -- <src> <dest>
|
||||
if call[0] != "install" || len(call) != 6 {
|
||||
t.Fatalf("call %d: unexpected vector %v", i, call)
|
||||
}
|
||||
src, dest := call[4], call[5]
|
||||
if dest != SnippetPath {
|
||||
t.Errorf("call %d: dest = %q, want %q", i, dest, SnippetPath)
|
||||
}
|
||||
if !randomName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q does not match the random felhom-guest-hook-*.sh pattern", i, src)
|
||||
}
|
||||
if fixedName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", i, src)
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
if srcs[0] == srcs[1] {
|
||||
t.Errorf("two consecutive installs staged through the SAME source path %q — must be random per call", srcs[0])
|
||||
}
|
||||
// Non-hollow: the staged file must actually carry the snippet body at install time.
|
||||
for i, c := range r.srcContent {
|
||||
if c != snippetBody {
|
||||
t.Errorf("call %d: staged content is not the snippet body (got %d bytes)", i, len(c))
|
||||
}
|
||||
}
|
||||
// And the temp is cleaned up after.
|
||||
for _, src := range srcs {
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("staged temp %q left behind (defer os.Remove missing)", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user