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
18 changes: 13 additions & 5 deletions cmd/ephemerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,19 @@ func serve(ctx context.Context, configFile, imagesDirFlag string, containerdTCPP
// with the existing bridge's IP.
networking.CleanStaleBridge(log)

// Control ports the in-VM daemon exposes on the bridge gateway that
// containers must never reach: containerd (TCP), the unauthenticated
// dispatch gRPC server (containerd+1), and the debug exec server
// (containerd+2). See startWorkerDebugExec / StartDispatchServer above.
controlPorts := []int{int(containerdTCPPort), int(containerdTCPPort) + 1, int(containerdTCPPort) + 2}

net, err := networking.New(networking.Config{
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
Log: log,
DataDir: configDir,
Subnet: cfg.Network.Subnet,
MTU: cfg.Network.MTU,
CNIBinDir: cm.Dir(),
ControlPorts: controlPorts,
Log: log,
})
if err != nil {
return fmt.Errorf("initializing networking: %w", err)
Expand Down Expand Up @@ -638,6 +645,7 @@ func serve(ctx context.Context, configFile, imagesDirFlag string, containerdTCPP
MacOSModeForRepo: cfg.Runner.MacOS.ModeForRepo,
NativeMacUser: cfg.Runner.MacOS.User,
RunnerDir: rm.Dir(),
PrivateKeyPath: cfg.GitHub.PrivateKeyPath,
Retry: retryCfg,
OrphanSweep: orphanCfg,
Log: log,
Expand Down
20 changes: 9 additions & 11 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,22 +206,20 @@ type DindConfig struct {
}

// ResolvedAllowPrivileged returns whether privileged dind sibling
// containers are allowed, applying the platform-aware default when the
// operator hasn't set the key explicitly.
// containers are allowed, applying the secure default when the operator
// hasn't set the key explicitly.
//
// Default policy:
// - Windows / macOS host → true. The dind containerd backing store
// runs inside a VM that ephemerd manages (WSL2/Hyper-V on Windows,
// Vz on macOS), so the worst-case escape stays inside that VM.
// - Linux host → false. ephemerd runs directly on the host with no
// VM fence, so a privileged escape is bare-metal-host compromise.
// Operators that trust their workloads (e.g. internal CI for KIND
// tests) can opt in via `allow_privileged = true`.
// Default policy: false on ALL platforms. Privileged is opt-in everywhere.
// A privileged sibling container is effectively root on whatever host runs
// the backing containerd, so shipping it on-by-default is unsafe even where
// a VM fence limits the blast radius (Windows/macOS) — an operator that
// needs it (KIND clusters, nested containerd, /dev/fuse) opts in explicitly
// with `allow_privileged = true`.
func (d *DindConfig) ResolvedAllowPrivileged() bool {
if d.AllowPrivileged != nil {
return *d.AllowPrivileged
}
return goruntime.GOOS != "linux"
return false
}

// DindCachePruneInterval returns the prune interval with the default
Expand Down
16 changes: 8 additions & 8 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1931,12 +1931,12 @@ owner = "testorg"
}
}

func TestResolvedAllowPrivileged_DefaultByOS(t *testing.T) {
func TestResolvedAllowPrivileged_DefaultOffEveryOS(t *testing.T) {
// Secure default (DIND-1): privileged is opt-in on ALL platforms, so the
// default must resolve false regardless of GOOS.
d := DindConfig{}
got := d.ResolvedAllowPrivileged()
wantTrue := goruntime.GOOS != "linux"
if got != wantTrue {
t.Errorf("default ResolvedAllowPrivileged on GOOS=%s = %v, want %v", goruntime.GOOS, got, wantTrue)
if d.ResolvedAllowPrivileged() {
t.Errorf("default ResolvedAllowPrivileged on GOOS=%s = true, want false (privileged must be opt-in)", goruntime.GOOS)
}
}

Expand Down Expand Up @@ -1976,9 +1976,9 @@ enabled = true
if cfg.Dind.AllowPrivileged != nil {
t.Errorf("AllowPrivileged ptr = %v, want nil (key not set in TOML)", *cfg.Dind.AllowPrivileged)
}
want := goruntime.GOOS != "linux"
if got := cfg.Dind.ResolvedAllowPrivileged(); got != want {
t.Errorf("ResolvedAllowPrivileged on GOOS=%s = %v, want %v", goruntime.GOOS, got, want)
// Omitted key → secure default false on every platform.
if got := cfg.Dind.ResolvedAllowPrivileged(); got {
t.Errorf("ResolvedAllowPrivileged on GOOS=%s = true, want false (default off)", goruntime.GOOS)
}
}

Expand Down
58 changes: 53 additions & 5 deletions pkg/native/native_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ type Runner struct {
log *slog.Logger

jobDir string // <dataDir>/native/<jobID>/
pemPath string // configured GitHub App private_key_path (empty if unset / PAT auth)
keychainPath string // per-job keychain
runAsUser string // existing user to run as (empty = _ephemerd service user)
jobUID uint32 // uid the runner executes as
Expand All @@ -211,7 +212,12 @@ func (r *Runner) SetRunAsUser(username string) {

// New creates a native macOS runner for a single job. It prepares the
// workspace directory structure but does not start the runner process.
func New(dataDir, jobID, jitConfig, runnerSrc string, log *slog.Logger) (*Runner, error) {
//
// pemPath is the configured GitHub App private_key_path (empty for PAT
// auth). It is threaded into the sandbox profile so the runner is denied
// read access to the operator's App private key even though the daemon
// runs as root with no HOME — see GenerateSandboxProfile.
func New(dataDir, jobID, jitConfig, runnerSrc, pemPath string, log *slog.Logger) (*Runner, error) {
jobDir := filepath.Join(dataDir, "native", jobID)

// Create workspace directories
Expand All @@ -233,6 +239,7 @@ func New(dataDir, jobID, jitConfig, runnerSrc string, log *slog.Logger) (*Runner
jobID: jobID,
jitConfig: jitConfig,
runnerSrc: runnerSrc,
pemPath: pemPath,
log: log,
jobDir: jobDir,
}, nil
Expand All @@ -250,7 +257,7 @@ func (r *Runner) Start(ctx context.Context) error {

// Generate and write sandbox profile
profilePath := filepath.Join(r.jobDir, "sandbox.sb")
profile := GenerateSandboxProfile(r.jobDir, r.dataDir)
profile := GenerateSandboxProfile(r.jobDir, r.dataDir, r.pemPath)
if err := os.WriteFile(profilePath, []byte(profile), 0o644); err != nil {
return fmt.Errorf("writing sandbox profile: %w", err)
}
Expand Down Expand Up @@ -444,7 +451,11 @@ func (r *Runner) deleteKeychain() {

// GenerateSandboxProfile returns a macOS sandbox profile that restricts
// the runner process. Paths are templated with the job-specific directories.
func GenerateSandboxProfile(jobDir, dataDir string) string {
//
// pemPath is the configured GitHub App private_key_path (may be empty).
// When set it is denied explicitly as a belt-and-suspenders measure on top
// of the broad /Users content deny below.
func GenerateSandboxProfile(jobDir, dataDir, pemPath string) string {
// Resolve to absolute, symlink-free paths. The sandbox matches against
// kernel (resolved) paths: /var and /tmp are symlinks to /private/var
// and /private/tmp on macOS, so rules written with the unresolved
Expand All @@ -463,6 +474,22 @@ func GenerateSandboxProfile(jobDir, dataDir string) string {
absDataDir := resolve(dataDir)
homeDir := resolve(os.Getenv("HOME"))

// Belt-and-suspenders deny for the configured GitHub App private key.
// The broad /Users content deny below already covers a key under any
// user's home, but the PEM can live outside /Users (e.g. /etc,
// /var/lib/ephemerd) so name it — and its parent dir — explicitly.
// Resolved through the same helper so symlinked config paths match the
// kernel's view.
var pemDenies string
if pemPath != "" {
absPem := resolve(pemPath)
pemDenies = fmt.Sprintf(`
;; === GitHub App private key (belt and suspenders) ===
(deny file-read* (literal "%[1]s"))
(deny file-read* (subpath "%[2]s"))
`, absPem, filepath.Dir(absPem))
}

// NOTE: this profile is allow-by-default with an explicit deny list.
// For native (no-VM) execution the stronger posture is deny-by-default
// with an allow list, but that requires enumerating every path the GHA
Expand Down Expand Up @@ -513,6 +540,27 @@ func GenerateSandboxProfile(jobDir, dataDir string) string {
;; contents) — job ids are not secret.
(allow file-read-data (literal "%[2]s/native"))

;; Block reading the CONTENTS of every user's home under /Users. The
;; daemon runs as root with no HOME, so a deny anchored on $HOME (below)
;; targets "/.ssh" and leaves the operator's real ~/.ssh — including the
;; GitHub App PEM and every other dotfile secret — readable under
;; (allow default). This closes that hole host-wide without depending on
;; the daemon's HOME.
;;
;; Deny file-read-DATA (not file-read*), mirroring the native/ subtree
;; technique above: on a file it blocks reading contents; on a directory
;; it blocks readdir. file-read-METADATA stays allowed so lstat/realpath
;; path resolution can traverse THROUGH /Users (denying metadata breaks
;; getcwd and the .NET host's executable-path resolution).
(deny file-read-data (subpath "/Users"))
(deny file-write* (subpath "/Users"))

;; Re-allow reading the /Users directory NODE itself (not its children),
;; same reason as the native/ node re-allow: getcwd()/bash walk UP through
;; /Users and must readdir it to resolve the current path. This leaks the
;; list of usernames (not their file contents) — usernames are not secret.
(allow file-read-data (literal "/Users"))

;; Block sensitive host paths entirely — read and write. .ssh was
;; previously read-only-denied, leaving a writable authorized_keys hole
;; on any host where the runner uid can reach the operator's home.
Expand All @@ -524,7 +572,7 @@ func GenerateSandboxProfile(jobDir, dataDir string) string {
(deny file-write* (literal "%[2]s/ephemerd.sock"))
(deny file-read* (subpath "%[2]s/vm"))
(deny file-write* (subpath "%[2]s/vm"))

%[4]s
;; Block writes to shared tools (read-only access only)
(deny file-write* (subpath "/opt/homebrew"))
(deny file-write* (subpath "/Applications"))
Expand All @@ -540,7 +588,7 @@ func GenerateSandboxProfile(jobDir, dataDir string) string {
(allow file-read-data (subpath "%[3]s"))
(allow file-write* (subpath "%[3]s"))
(allow file-write* (subpath "/private/tmp"))
`, homeDir, absDataDir, absJobDir)
`, homeDir, absDataDir, absJobDir, pemDenies)
}

// copyRunnerFiles copies the runner directory to the per-job location.
Expand Down
103 changes: 100 additions & 3 deletions pkg/native/native_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ func TestGenerateSandboxProfile(t *testing.T) {
t.Fatal(err)
}

profile := GenerateSandboxProfile(jobDir, dataDir)
profile := GenerateSandboxProfile(jobDir, dataDir, "")

checks := []struct {
desc string
substr string
}{
{"denies /Users content reads", `(deny file-read-data (subpath "/Users"))`},
{"denies /Users writes", `(deny file-write* (subpath "/Users"))`},
{"re-allows /Users node read (getcwd)", `(allow file-read-data (literal "/Users"))`},
{"allows DNS UDP", `(allow network-outbound (remote udp "localhost:53"))`},
{"allows DNS TCP", `(allow network-outbound (remote tcp "localhost:53"))`},
{"blocks localhost", `(deny network-outbound (remote ip "localhost:*"))`},
Expand Down Expand Up @@ -87,7 +90,7 @@ func TestGenerateSandboxProfile_ResolvesSymlinks(t *testing.T) {

// Generate using the SYMLINK path — the profile must contain the
// resolved target, and not rules pointing at the symlink.
profile := GenerateSandboxProfile(filepath.Join(linkData, "native", "j1"), linkData)
profile := GenerateSandboxProfile(filepath.Join(linkData, "native", "j1"), linkData, "")

if !strings.Contains(profile, `(deny file-read-data (subpath "`+resolvedData+`/native"))`) {
t.Errorf("profile should deny the RESOLVED native path %q, got:\n%s", resolvedData, profile)
Expand All @@ -97,6 +100,100 @@ func TestGenerateSandboxProfile_ResolvesSymlinks(t *testing.T) {
}
}

// TestGenerateSandboxProfile_PEMDenies pins NATIVE-1: when the GitHub App
// private_key_path is configured, the profile must deny reading it (and its
// parent dir) explicitly, on top of the broad /Users content deny. The deny
// must not depend on the daemon's HOME.
func TestGenerateSandboxProfile_PEMDenies(t *testing.T) {
base := t.TempDir()
dataDir := filepath.Join(base, "data")
jobDir := filepath.Join(dataDir, "native", "job123")
if err := os.MkdirAll(jobDir, 0o755); err != nil {
t.Fatal(err)
}

// A PEM that lives OUTSIDE /Users (e.g. an operator's config dir) — the
// /Users deny wouldn't cover it, so the explicit deny must.
pemDir := filepath.Join(base, "secrets")
if err := os.MkdirAll(pemDir, 0o755); err != nil {
t.Fatal(err)
}
pemPath := filepath.Join(pemDir, "app.pem")
if err := os.WriteFile(pemPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil {
t.Fatal(err)
}
resolvedPem, err := filepath.EvalSymlinks(pemPath)
if err != nil {
t.Fatal(err)
}
resolvedPemDir := filepath.Dir(resolvedPem)

profile := GenerateSandboxProfile(jobDir, dataDir, pemPath)

wantLiteral := `(deny file-read* (literal "` + resolvedPem + `"))`
if !strings.Contains(profile, wantLiteral) {
t.Errorf("profile missing explicit PEM literal deny %q, got:\n%s", wantLiteral, profile)
}
wantDir := `(deny file-read* (subpath "` + resolvedPemDir + `"))`
if !strings.Contains(profile, wantDir) {
t.Errorf("profile missing PEM parent-dir deny %q, got:\n%s", wantDir, profile)
}
}

// TestGenerateSandboxProfile_NoPEM confirms the PEM deny block is omitted
// entirely when no private_key_path is configured (PAT auth), so an empty
// literal deny never lands in the profile.
func TestGenerateSandboxProfile_NoPEM(t *testing.T) {
base := t.TempDir()
dataDir := filepath.Join(base, "data")
jobDir := filepath.Join(dataDir, "native", "job123")
if err := os.MkdirAll(jobDir, 0o755); err != nil {
t.Fatal(err)
}

profile := GenerateSandboxProfile(jobDir, dataDir, "")
if strings.Contains(profile, `(literal ""))`) {
t.Errorf("profile must not emit an empty literal deny when no PEM is set, got:\n%s", profile)
}
if strings.Contains(profile, "GitHub App private key") {
t.Errorf("PEM deny block should be absent when no PEM is set, got:\n%s", profile)
}
}

// TestGenerateSandboxProfile_JobHomeUnaffected confirms the new /Users
// content deny does not touch a job whose HOME lives under the data dir
// (e.g. /var/lib/ephemerd/native/<job>/home): that path is re-allowed for
// read AND write, so the runner's own home is fully usable.
func TestGenerateSandboxProfile_JobHomeUnaffected(t *testing.T) {
// Use a data dir under a non-/Users root to mirror /var/lib/ephemerd.
base := t.TempDir() // t.TempDir() on macOS resolves under /private/var
dataDir := filepath.Join(base, "ephemerd")
jobDir := filepath.Join(dataDir, "native", "job123")
if err := os.MkdirAll(jobDir, 0o755); err != nil {
t.Fatal(err)
}
resolvedJob, err := filepath.EvalSymlinks(jobDir)
if err != nil {
t.Fatal(err)
}

profile := GenerateSandboxProfile(jobDir, dataDir, "")

// The job dir (which contains home/) must be re-allowed for read+write,
// and must not sit under /Users so the /Users deny can't reach it.
if strings.HasPrefix(resolvedJob, "/Users") {
t.Fatalf("test setup error: job dir unexpectedly under /Users: %s", resolvedJob)
}
for _, want := range []string{
`(allow file-read* (subpath "` + resolvedJob + `"))`,
`(allow file-write* (subpath "` + resolvedJob + `"))`,
} {
if !strings.Contains(profile, want) {
t.Errorf("profile missing job-home re-allow %q, got:\n%s", want, profile)
}
}
}

func TestNewCreatesWorkspace(t *testing.T) {
tmpDir := t.TempDir()
dataDir := filepath.Join(tmpDir, "data")
Expand All @@ -107,7 +204,7 @@ func TestNewCreatesWorkspace(t *testing.T) {
t.Fatal(err)
}

r, err := New(dataDir, "test-job-42", "fake-jit-config", runnerSrc, nil)
r, err := New(dataDir, "test-job-42", "fake-jit-config", runnerSrc, "", nil)
if err != nil {
t.Fatalf("New() error: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/native/native_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
type Runner struct{}

// New returns an error on non-darwin platforms.
func New(_, _, _, _ string, _ *slog.Logger) (*Runner, error) {
func New(_, _, _, _, _ string, _ *slog.Logger) (*Runner, error) {
return nil, fmt.Errorf("native macOS runner is only supported on darwin")
}

Expand Down
Loading