Skip to content

Commit b3b67e6

Browse files
JAORMXclaude
andcommitted
Harden guest boot with sysctls, cap drop, and /root lockdown
Integrate propolis guest/harden into the boot sequence: apply kernel sysctl defaults after /proc mount, chmod /root to 0700, and drop all capabilities except SETUID/SETGID/NET_BIND_SERVICE before starting the SSH server. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1ed0775 commit b3b67e6

1 file changed

Lines changed: 37 additions & 3 deletions

File tree

internal/guest/boot/boot.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/stacklok/apiary/internal/guest/mount"
2121
"github.com/stacklok/apiary/internal/guest/network"
2222
"github.com/stacklok/apiary/internal/guest/sshd"
23+
"github.com/stacklok/propolis/guest/harden"
2324
)
2425

2526
// Run executes the full guest boot sequence and returns a shutdown function
@@ -44,19 +45,43 @@ func Run(logger *slog.Logger) (shutdown func(), err error) {
4445
logger.Warn("workspace mount failed, continuing without workspace", "error", err)
4546
}
4647

47-
// 4. Load environment file.
48+
// 4. Apply kernel sysctl hardening (needs /proc mounted).
49+
harden.KernelDefaults(logger)
50+
51+
// 5. Lock down /root/ so the sandbox user cannot read it.
52+
lockdownRoot(logger)
53+
54+
// 6. Load environment file.
4855
envVars, err := env.Load("/etc/sandbox-env")
4956
if err != nil {
5057
return nil, fmt.Errorf("loading environment: %w", err)
5158
}
5259

53-
// 5. Parse authorized keys.
60+
// 7. Parse authorized keys.
5461
authorizedKeys, err := parseAuthorizedKeys("/home/sandbox/.ssh/authorized_keys")
5562
if err != nil {
5663
return nil, fmt.Errorf("parsing authorized keys: %w", err)
5764
}
5865

59-
// 6. Start SSH server — bind synchronously so listen errors surface
66+
// 8. Drop unneeded capabilities from the bounding set. Keep only
67+
// what sshd needs: SETUID/SETGID for credential switching,
68+
// NET_BIND_SERVICE for port 22. This must be the last privileged
69+
// operation before starting the SSH server.
70+
//
71+
// This is fatal because PR_CAPBSET_DROP has been available since
72+
// Linux 2.6.25 — failure indicates a serious problem (not root,
73+
// kernel bug) and continuing with a full bounding set defeats the
74+
// hardening entirely.
75+
logger.Info("dropping unnecessary capabilities")
76+
if err := harden.DropBoundingCaps(
77+
harden.CapSetUID,
78+
harden.CapSetGID,
79+
harden.CapNetBindService,
80+
); err != nil {
81+
return nil, fmt.Errorf("dropping capabilities: %w", err)
82+
}
83+
84+
// 9. Start SSH server — bind synchronously so listen errors surface
6085
// immediately rather than being swallowed in a goroutine.
6186
cfg := sshd.Config{
6287
Port: 22,
@@ -90,6 +115,15 @@ func Run(logger *slog.Logger) (shutdown func(), err error) {
90115
return func() { srv.Close() }, nil
91116
}
92117

118+
// lockdownRoot sets /root/ to mode 0700 so the sandbox user cannot read
119+
// its contents (MCP bootstrap config, debug logs, etc.).
120+
func lockdownRoot(logger *slog.Logger) {
121+
logger.Info("locking down /root permissions")
122+
if err := os.Chmod("/root", 0o700); err != nil {
123+
logger.Warn("failed to chmod /root", "error", err)
124+
}
125+
}
126+
93127
// parseAuthorizedKeys reads an authorized_keys file and returns the parsed
94128
// public keys. Returns an error if no valid keys are found.
95129
func parseAuthorizedKeys(path string) ([]ssh.PublicKey, error) {

0 commit comments

Comments
 (0)