diff --git a/README.md b/README.md index d8a0d84a2..605e5bcf1 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,21 @@ zero sandbox policy zero sandbox grants list ``` +On Unix, the credential baseline discovers SSH private keys and GPG stores. +SSH discovery reads directories in pages and walks nested directories with cycle +detection. Large or deeply nested directories do not trigger a discovery limit. +Exceeding a config-size or Include limit, or failing to inspect a required input, +refuses sandboxed execution rather than using a partial list of protected keys. + +Linux's mount-based backend refuses selective SSH-key denies, including key paths +that do not exist yet, and credential denies through mutable symlinks. This also +applies on machines without SSH keys: a key created later must remain protected. +An explicit deny of an existing containing directory can cover the keys, but also +hides that directory's public files, +including SSH configuration and known hosts. Explicit Linux `denyRead` paths +must already exist. macOS uses pathname rules; automatic credential discovery +remains disabled on Windows. + ## Web And Local Control Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs, diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 53ffd7b4d..9536398a6 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -561,14 +561,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp profile, _ := plan["permissionProfile"].(map[string]any) fileSystem, _ := profile["fileSystem"].(map[string]any) wantDenyRead := []string(nil) - credentialHome := emptyHome + wantSSHFiles := []string(nil) if runtime.GOOS != "windows" { + credentialHome := emptyHome if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil { credentialHome = resolved } wantDenyRead = []string{ filepath.Join(credentialHome, ".aws"), filepath.Join(credentialHome, ".azure"), + filepath.Join(credentialHome, ".gnupg"), + filepath.Join(credentialHome, ".ssh", "id_rsa"), + filepath.Join(credentialHome, ".ssh", "id_dsa"), + filepath.Join(credentialHome, ".ssh", "id_ecdsa"), + filepath.Join(credentialHome, ".ssh", "id_ed25519"), + filepath.Join(credentialHome, ".ssh", "id_ecdsa_sk"), + filepath.Join(credentialHome, ".ssh", "id_ed25519_sk"), // git's cleartext credential stores, in both the home and XDG // layouts (#816). Listed here so the exported policy JSON is what // catches a regression: this baseline is the contract a user reads @@ -583,6 +591,25 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp filepath.Join(credentialHome, ".config", "gcloud"), filepath.Join(credentialHome, ".config", "zero"), } + for _, path := range wantDenyRead { + if filepath.Dir(path) == filepath.Join(credentialHome, ".ssh") { + wantSSHFiles = append(wantSSHFiles, path) + } + } + if emptyHome != credentialHome { + for _, rel := range []string{ + ".git-credentials", + ".gnupg", + filepath.Join(".ssh", "id_rsa"), + filepath.Join(".ssh", "id_dsa"), + filepath.Join(".ssh", "id_ecdsa"), + filepath.Join(".ssh", "id_ed25519"), + filepath.Join(".ssh", "id_ecdsa_sk"), + filepath.Join(".ssh", "id_ed25519_sk"), + } { + wantDenyRead = append(wantDenyRead, filepath.Join(emptyHome, rel)) + } + } } gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"]) sort.Strings(gotDenyRead) @@ -590,9 +617,19 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp if !reflect.DeepEqual(gotDenyRead, wantDenyRead) { t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead) } + gotSSHFiles := jsonStringSlice(fileSystem["sshDenyReadFiles"]) + sort.Strings(gotSSHFiles) + sort.Strings(wantSSHFiles) + if !reflect.DeepEqual(gotSSHFiles, wantSSHFiles) { + t.Fatalf("manager absent SSH key protection = %#v, want %#v", gotSSHFiles, wantSSHFiles) + } wantCarveouts := []string(nil) wantEnsureDirs := []string(nil) if runtime.GOOS != "windows" { + credentialHome := emptyHome + if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil { + credentialHome = resolved + } zeroDir := filepath.Join(credentialHome, ".config", "zero") wantCarveouts = []string{ filepath.Join(zeroDir, "plugins"), @@ -601,13 +638,20 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp } wantEnsureDirs = []string{zeroDir} } - if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) { + gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]) + sort.Strings(gotCarveouts) + sort.Strings(wantCarveouts) + if !reflect.DeepEqual(gotCarveouts, wantCarveouts) { t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts) } - if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) { + gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]) + sort.Strings(gotEnsureDirs) + sort.Strings(wantEnsureDirs) + if !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) { t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs) } delete(fileSystem, "denyReadIfExists") + delete(fileSystem, "sshDenyReadFiles") delete(fileSystem, "denyReadCarveouts") delete(fileSystem, "ensureDenyReadDirs") fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots) diff --git a/internal/sandbox/architecture_baseline_test.go b/internal/sandbox/architecture_baseline_test.go index b321a0e24..31435e09e 100644 --- a/internal/sandbox/architecture_baseline_test.go +++ b/internal/sandbox/architecture_baseline_test.go @@ -76,7 +76,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) { root := t.TempDir() engine := NewEngine(EngineOptions{ WorkspaceRoot: root, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Backend: Backend{ Name: BackendLinuxBwrap, Available: true, @@ -90,6 +90,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) if plan.TargetBackend != BackendLinuxBwrap || !plan.Wrapped || plan.EnforcementLevel != EnforcementNative || plan.DowngradeReason != "" { t.Fatalf("wrapped command metadata = %#v, want native linux-bwrap", plan) @@ -107,6 +108,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan unavailable auto plan: %v", err) } + t.Cleanup(degraded.Cleanup) if degraded.Wrapped || degraded.EnforcementLevel != EnforcementDegraded || degraded.DowngradeReason != "native sandbox unavailable" { t.Fatalf("unavailable command metadata = %#v, want degraded direct plan", degraded) } diff --git a/internal/sandbox/command_policy_test.go b/internal/sandbox/command_policy_test.go new file mode 100644 index 000000000..1ea2c3921 --- /dev/null +++ b/internal/sandbox/command_policy_test.go @@ -0,0 +1,34 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// testPolicyWithSSHDirectoryDeny gives command-planning tests a maskable SSH +// policy so they reach their intended network, runtime, or other credential +// checks. Supplied homes must belong to the test; otherwise create an isolated +// home and redirect all credential roots before constructing the profile. +func testPolicyWithSSHDirectoryDeny(t *testing.T, homes ...string) Policy { + t.Helper() + if len(homes) == 0 { + home := t.TempDir() + homes = []string{home} + for _, name := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "APPDATA", "LOCALAPPDATA"} { + t.Setenv(name, home) + } + } + for _, name := range []string{"ZERO_OAUTH_TOKENS_PATH", "ZERO_MCP_OAUTH_TOKENS_PATH", "GNUPGHOME", "ZERO_OAUTH_STORAGE", "CLOUDSDK_CONFIG", "GH_CONFIG_DIR", "DOCKER_CONFIG", "KUBECONFIG", "NETRC", "GOOGLE_APPLICATION_CREDENTIALS", "NPM_CONFIG_USERCONFIG", "npm_config_userconfig"} { + t.Setenv(name, "") + } + policy := DefaultPolicy() + for _, home := range homes { + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + t.Fatal(err) + } + policy.DenyRead = append(policy.DenyRead, sshDir) + } + return policy +} diff --git a/internal/sandbox/git_credential_deny_test.go b/internal/sandbox/git_credential_deny_test.go index 7296a295d..6bdb9b3b4 100644 --- a/internal/sandbox/git_credential_deny_test.go +++ b/internal/sandbox/git_credential_deny_test.go @@ -16,12 +16,10 @@ import ( // git's credential store holds host passwords and personal access tokens in // cleartext, in one of two locations depending on whether the user is on the // XDG layout. Neither was denied, so a sandboxed command could read them -// (#815). -// -// Scoped to the credential files on purpose. Denying ~/.ssh as well would stop -// a sandboxed git push over SSH from working, which is a functional trade that -// issue tracks separately; these two cost nothing, because git reads them for -// authentication rather than identity. +// (#815). #816 closed this half: the stores are denied as files, not the +// surrounding git config directory. SSH private keys and the GPG keyring are +// the remaining #815 scope and are covered in ssh_gpg_deny_test.go (key +// material, not the whole of ~/.ssh). func TestCredentialDenyReadPathsCoversGitCredentialStores(t *testing.T) { home := t.TempDir() configHome := filepath.Join(home, ".config") diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index f3ea5c457..d6bdbe2ef 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -59,6 +59,7 @@ type linuxSandboxBwrapPlan struct { } type linuxBwrapFilesystemPlan struct { + Err error Args []string ProtectedCreateTargets []string } @@ -189,6 +190,9 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB "--die-with-parent", } filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile) + if filesystemPlan.Err != nil { + return linuxSandboxBwrapPlan{}, filesystemPlan.Err + } args = append(args, filesystemPlan.Args...) if pathExists(helperPath) { args = append(args, "--ro-bind", helperPath, helperPath) @@ -221,6 +225,22 @@ func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxB } func validateLinuxBwrapPermissionProfile(profile PermissionProfile) error { + if problems := profile.FileSystem.CredentialDiscoveryErrors; len(problems) > 0 { + return fmt.Errorf("cannot guarantee credential protection: %s", strings.Join(problems, "; ")) + } + for _, path := range profile.FileSystem.DenyRead { + if _, err := os.Lstat(path); err != nil { + return fmt.Errorf("bubblewrap cannot guarantee an explicit deny for %s: %w", path, err) + } + } + if len(profile.FileSystem.SSHDenyReadFiles) > 0 { + return fmt.Errorf("bubblewrap cannot guarantee selective SSH key protection across concurrent path replacement; deny the containing directory explicitly or use a pathname-policy backend") + } + for _, path := range append(append([]string{}, profile.FileSystem.DenyRead...), profile.FileSystem.DenyReadIfExists...) { + if linuxNonPlatformSymlinkInPath(path) { + return fmt.Errorf("bubblewrap cannot guarantee deny-read protection through a mutable symlink: %s", path) + } + } if files := profile.FileSystem.ProcessTrustedDenyReadFiles; len(files) > 0 { return fmt.Errorf("bubblewrap cannot securely deny credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", ")) } @@ -303,26 +323,35 @@ func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesyst for _, path := range fs.DenyWrite { args = appendReadOnlyLinuxPathArgs(args, path) } - for _, path := range fs.DenyRead { - args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) - } + var unreadable []string + unreadable = append(unreadable, fs.DenyRead...) // The profile includes only trusted, process-environment-derived directories // here. Command-controlled credential roots remain deny-if-present and must // never cause host filesystem mutations before sandbox launch. ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs) for _, path := range fs.DenyReadIfExists { - if !pathExists(path) { + if !pathExists(path) && !pathExistsNoFollow(path) { // A baseline credential path is emitted for every run, so an absent // entry is the common case on a fresh machine — a third-party store // such as ~/.aws that Zero must not create. The read-all profile starts // from a read-only host-root bind where bubblewrap cannot create a // missing mount destination, and masking the nearest existing parent // could hide HOME, /tmp, or the workspace. Path-based backends - // (seatbelt) still deny these paths before they exist. + // (seatbelt) still deny these paths before they exist. A dangling + // symlink still exists as a pathname and must be masked so a later + // retarget cannot reopen it. continue } - args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts) + unreadable = append(unreadable, path) + } + classified := classifyUnreadableLinuxPaths(unreadable) + if classified.err != nil { + return linuxBwrapFilesystemPlan{Err: classified.err} } + if len(classified.links) > 0 { + return linuxBwrapFilesystemPlan{Err: errors.New("bubblewrap cannot guarantee deny-read protection through a mutable symlink")} + } + args = appendClassifiedUnreadableLinuxPaths(args, classified, fs.DenyReadCarveouts) return linuxBwrapFilesystemPlan{ Args: args, ProtectedCreateTargets: dedupeStrings(protectedCreateTargets), @@ -397,14 +426,111 @@ func appendReadOnlyLinuxPathArgs(args []string, path string) []string { return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path) } -func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string { - path = normalizeProfilePath(path) - if path == "" { - return args +// Denies are classified once; uncertain entries abort planning before args are used. +func appendClassifiedUnreadableLinuxPaths(args []string, classified linuxUnreadableClassified, carveouts []string) []string { + for _, dir := range classified.dirs { + args = appendUnreadableLinuxDirArgs(args, dir, carveouts) + } + for _, file := range classified.files { + args = append(args, "--ro-bind", "/dev/null", file) + } + return args +} + +type linuxUnreadableClassified struct { + err error + files []string + dirs []string + links []string +} + +func classifyUnreadableLinuxPaths(paths []string) linuxUnreadableClassified { + var out linuxUnreadableClassified + seen := make(map[string]struct{}, len(paths)) + add := func(bucket *[]string, path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + *bucket = append(*bucket, path) + } + for _, path := range paths { + lexical := normalizeProfilePathLexically(path) + canonical := normalizeProfilePath(path) + inspect := lexical + if inspect == "" { + inspect = canonical + } + if inspect == "" { + out.err = fmt.Errorf("cannot classify deny-read path %q", path) + return out + } + info, err := os.Lstat(inspect) + if err != nil && canonical != "" && canonical != inspect { + info, err = os.Lstat(canonical) + inspect = canonical + } + if err != nil { + out.err = fmt.Errorf("cannot classify deny-read path %s: %w", path, err) + return out + } + switch { + case info.Mode().Type() == os.ModeSymlink: + // Keep the lexical dentry so a later retarget still hits the dest. + add(&out.links, inspect) + case info.IsDir(): + dest := inspect + if canonical != "" && !linuxNonPlatformSymlinkInPath(inspect) { + dest = canonical + } + add(&out.dirs, dest) + default: + dest := inspect + if canonical != "" && !linuxNonPlatformSymlinkInPath(inspect) { + dest = canonical + } + add(&out.files, dest) + } + } + return out +} + +// linuxNonPlatformSymlinkInPath reports a symlink in path's resolution other +// than host aliases such as macOS /var -> /private/var. Those aliases should +// use the canonical bwrap dest so overlay and file binds name the same place. +// A credential directory symlink (for example ~/.ssh -> a store) must keep the +// lexical dest so a later retarget is still denied. +func linuxNonPlatformSymlinkInPath(path string) bool { + current := normalizeProfilePathLexically(path) + if current == "" { + current = filepath.Clean(path) + } + for { + info, err := os.Lstat(current) + if err == nil && info.Mode().Type() == os.ModeSymlink && !linuxPlatformPrefixSymlink(current) { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + current = parent } - if info, err := os.Stat(path); err == nil && !info.IsDir() { - return append(args, "--ro-bind", "/dev/null", path) +} + +func linuxPlatformPrefixSymlink(path string) bool { + switch filepath.Clean(path) { + case "/var", "/etc", "/tmp", "/private/var", "/private/etc", "/private/tmp": + return true + default: + return false } +} + +func appendUnreadableLinuxDirArgs(args []string, path string, carveouts []string) []string { nested := nestedCarveoutPaths(path, carveouts) if len(nested) == 0 { return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path) @@ -416,7 +542,7 @@ func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []strin // --remount-ro, which is what freezes the tmpfs. args = append(args, "--perms", "111", "--tmpfs", path) for _, carveout := range nested { - if info, err := os.Lstat(carveout); err == nil && info.IsDir() { + if info, err := os.Lstat(carveout); err == nil && info.Mode()&os.ModeSymlink == 0 { args = append(args, "--ro-bind", carveout, carveout) } } @@ -471,6 +597,14 @@ func pathExists(path string) bool { return err == nil } +func pathExistsNoFollow(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + _, err := os.Lstat(path) + return err == nil +} + func findLinuxSandboxHelperCommand() (LinuxSandboxHelperCommand, error) { if exe, err := os.Executable(); err == nil { candidate := filepath.Join(filepath.Dir(exe), LinuxSandboxHelperName) diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index f3edd61eb..2debaebd4 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -94,7 +94,7 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) { } args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{ SandboxPolicyCWD: "/workspace", - PermissionProfile: DefaultPermissionProfile("/workspace"), + PermissionProfile: PermissionProfileFromPolicy("/workspace", testPolicyWithSSHDirectoryDeny(t), nil), BlockUnixSockets: true, Command: []string{"true"}, }) @@ -150,7 +150,7 @@ func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) { if err := os.WriteFile(helperPath, []byte("helper"), 0o755); err != nil { t.Fatalf("WriteFile helper: %v", err) } - profile := DefaultPermissionProfile("/workspace") + profile := PermissionProfileFromPolicy("/workspace", testPolicyWithSSHDirectoryDeny(t), nil) profile.Network = NetworkPolicy{Mode: NetworkAllow} args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{ SandboxPolicyCWD: "/workspace", diff --git a/internal/sandbox/manager_test.go b/internal/sandbox/manager_test.go index 6e2941602..12813817a 100644 --- a/internal/sandbox/manager_test.go +++ b/internal/sandbox/manager_test.go @@ -874,7 +874,6 @@ func TestPermissionProfileUnionsProcessAndCommandCredentialRootsWithoutCreatingC t.Setenv("USERPROFILE", parentHome) t.Setenv("XDG_CONFIG_HOME", parentConfig) t.Setenv("CLOUDSDK_CONFIG", "") - t.Setenv("ZERO_OAUTH_TOKENS_PATH", parentToken) t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") t.Setenv("NPM_CONFIG_USERCONFIG", "") @@ -888,9 +887,14 @@ func TestPermissionProfileUnionsProcessAndCommandCredentialRootsWithoutCreatingC childHome := filepath.Join(workspace, "child-home") childConfig := filepath.Join(childHome, "config") childToken := filepath.Join(workspace, "child-store", "tokens.json") + policy := testPolicyWithSSHDirectoryDeny(t, parentHome, childHome) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", parentToken) + // The unavailable backend cannot enforce explicit denies. Grant only these + // empty, test-owned SSH directories to isolate the token-root contract. + policy.AllowRead, policy.DenyRead = policy.DenyRead, nil engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: policy, Backend: Backend{Name: BackendUnavailable, Platform: runtime.GOOS}, }) plan, err := engine.BuildCommandPlan(CommandSpec{ @@ -984,7 +988,7 @@ func TestCommandSuppliedTokenOverrideFailsClosedOnBubblewrap(t *testing.T) { t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") workspace := t.TempDir() - baseline := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, nil) + baseline := permissionProfileFromPolicy(workspace, testPolicyWithSSHDirectoryDeny(t, home), nil, workspace, nil) if len(baseline.FileSystem.ProcessTrustedDenyReadFiles) != 0 || len(baseline.FileSystem.CommandDenyReadFinalFiles) != 0 { t.Fatalf("baseline profile should have no replaceable final files: %#v", baseline.FileSystem) } @@ -999,7 +1003,7 @@ func TestCommandSuppliedTokenOverrideFailsClosedOnBubblewrap(t *testing.T) { } commandToken := filepath.Join(tempDirOutsideDefaultTemp(t), "command-store", "tokens.json") - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, + profile := permissionProfileFromPolicy(workspace, testPolicyWithSSHDirectoryDeny(t, home), nil, workspace, []string{"ZERO_OAUTH_TOKENS_PATH=" + commandToken}) fs := profile.FileSystem if !stringSliceContains(fs.CommandDenyReadFinalFiles, normalizeProfilePath(commandToken)) { @@ -1044,9 +1048,10 @@ func TestKeyringOAuthOverrideDoesNotFailClosedOnBubblewrap(t *testing.T) { tokenPath := filepath.Join(tempDirOutsideDefaultTemp(t), "tokens.json") t.Run("process environment", func(t *testing.T) { + policy := testPolicyWithSSHDirectoryDeny(t, home) t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) t.Setenv("ZERO_OAUTH_STORAGE", "keyring") - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, nil) + profile := permissionProfileFromPolicy(workspace, policy, nil, workspace, nil) fs := profile.FileSystem if !stringSliceContains(fs.DenyReadIfExists, normalizeProfilePath(tokenPath)) { t.Fatalf("DenyReadIfExists = %#v, want keyring override retained in ordinary deny baseline", fs.DenyReadIfExists) @@ -1073,7 +1078,7 @@ func TestKeyringOAuthOverrideDoesNotFailClosedOnBubblewrap(t *testing.T) { t.Run("command environment", func(t *testing.T) { t.Setenv("ZERO_OAUTH_TOKENS_PATH", "") t.Setenv("ZERO_OAUTH_STORAGE", "") - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, []string{ + profile := permissionProfileFromPolicy(workspace, testPolicyWithSSHDirectoryDeny(t, home), nil, workspace, []string{ "ZERO_OAUTH_TOKENS_PATH=" + tokenPath, "ZERO_OAUTH_STORAGE=keyring", }) @@ -1108,7 +1113,7 @@ func TestCommandCredentialDirectoriesFailClosedWithoutHostMutation(t *testing.T) workspace := t.TempDir() commandConfig := filepath.Join(tempDirOutsideDefaultTemp(t), "missing-command-config") commandZero := filepath.Join(commandConfig, "zero") - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, []string{ + profile := permissionProfileFromPolicy(workspace, testPolicyWithSSHDirectoryDeny(t, home, filepath.Dir(commandConfig)), nil, workspace, []string{ "HOME=" + filepath.Dir(commandConfig), "XDG_CONFIG_HOME=" + commandConfig, }) @@ -1234,10 +1239,11 @@ func TestKeyringExceptionKeepsFileBackedTokenStoresFailClosed(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + policy := testPolicyWithSSHDirectoryDeny(t, home) for key, value := range test.processEnv { t.Setenv(key, value) } - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, test.commandEnv) + profile := permissionProfileFromPolicy(workspace, policy, nil, workspace, test.commandEnv) markers := profile.FileSystem.ProcessTrustedDenyReadFiles if test.commandMarker { markers = profile.FileSystem.CommandDenyReadFinalFiles @@ -1279,8 +1285,9 @@ func TestLegacyMCPOverrideDoesNotFailClosedOnBubblewrap(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + policy := testPolicyWithSSHDirectoryDeny(t, home) t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", test.processMCP) - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, test.commandEnv) + profile := permissionProfileFromPolicy(workspace, policy, nil, workspace, test.commandEnv) fs := profile.FileSystem for _, want := range []string{legacy, legacy + ".migrated"} { if !stringSliceContains(fs.DenyReadIfExists, normalizeProfilePath(want)) { @@ -1337,9 +1344,10 @@ func TestOAuthOverridesInsideCredentialCarveoutsRemainFailClosedOnBubblewrap(t * for _, name := range []string{"plugins", "specialists", "commands"} { t.Run(name, func(t *testing.T) { + policy := testPolicyWithSSHDirectoryDeny(t, home) token := filepath.Join(configDir, "zero", name, "tokens.json") t.Setenv("ZERO_OAUTH_TOKENS_PATH", token) - profile := permissionProfileFromPolicy(workspace, DefaultPolicy(), nil, workspace, nil) + profile := permissionProfileFromPolicy(workspace, policy, nil, workspace, nil) for _, want := range []string{token, token + ".secret"} { if !stringSliceContains(profile.FileSystem.ProcessTrustedDenyReadFiles, normalizeCredentialFinalPath(want)) { t.Fatalf("ProcessTrustedDenyReadFiles = %#v, carveout must retain final-file marker %q", profile.FileSystem.ProcessTrustedDenyReadFiles, want) @@ -1476,9 +1484,9 @@ func TestProcessTrustedExactAllowReadDoesNotIncludeSecret(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") - policy := DefaultPolicy() + policy := testPolicyWithSSHDirectoryDeny(t, home) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) policy.AllowRead = []string{tokenPath} profile := PermissionProfileFromPolicy(t.TempDir(), policy, nil) @@ -1528,16 +1536,17 @@ func TestEngineBuildCommandPlanValidatesBwrapBeforeCreatingRuntime(t *testing.T) t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) - t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "") oldUserCacheDir := sandboxUserCacheDir sandboxUserCacheDir = func() (string, error) { return runtimeCache, nil } t.Cleanup(func() { sandboxUserCacheDir = oldUserCacheDir }) workspace := t.TempDir() + policy := testPolicyWithSSHDirectoryDeny(t, home) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokenPath) engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: policy, Backend: Backend{ Name: BackendLinuxBwrap, Available: true, Platform: "linux", Executable: "/usr/bin/zero-linux-sandbox", diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 5cd97a9bb..1c56ef664 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -30,6 +30,12 @@ type FileSystemPolicy struct { // path-based policies can protect future paths; mount-based Linux only // masks entries that exist when the namespace is assembled. DenyReadIfExists []string `json:"denyReadIfExists,omitempty"` + // CredentialDiscoveryErrors prevent execution with an incomplete baseline. + CredentialDiscoveryErrors []string `json:"credentialDiscoveryErrors,omitempty"` + // SSHDenyReadFiles require a pathname deny. Linux cannot safely rebuild + // their parents from mutable sibling pathnames to mask individual keys. + // Absent candidates remain here because a host writer may create them later. + SSHDenyReadFiles []string `json:"sshDenyReadFiles,omitempty"` // DenyReadCarveouts are subtrees that stay readable INSIDE a denied root. // They exist so a directory-level credential deny can also cover the files // a store publishes (arbitrary temporary names, files created later in the @@ -149,6 +155,7 @@ func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco }) } userDenyRead := normalizeProfilePaths(policy.DenyRead) + userDenyRead = appendLexicalCredentialDenyPaths(userDenyRead, nil, policy.DenyRead) commandAllowedRoots := append([]string{}, roots...) for _, root := range readRoots { if root != profileRootPath() { @@ -164,6 +171,8 @@ func permissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco WriteRoots: writeRoots, DenyRead: userDenyRead, DenyReadIfExists: credentials.Paths, + CredentialDiscoveryErrors: credentials.DiscoveryErrors, + SSHDenyReadFiles: credentials.SSHFiles, DenyReadCarveouts: credentials.Carveouts, EnsureDenyReadDirs: credentials.EnsureDirs, ProcessTrustedDenyReadFiles: credentials.ProcessTrustedFinalFiles, @@ -219,6 +228,8 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop // them, the trusted non-secret subtrees that stay readable, and the trusted // Zero-owned directories a mount-based backend may create so its mask exists. type credentialDenyPaths struct { + DiscoveryErrors []string + SSHFiles []string Paths []string Carveouts []string EnsureDirs []string @@ -295,6 +306,8 @@ func credentialDenyReadPaths(policy Policy, commandDir string, commandEnv []stri processDirs := append([]string{}, trusted.Dirs...) appendUntrusted := func(options credentialPathOptions) { paths := credentialDenyReadPathsIn(options, policy.AllowRead) + trusted.DiscoveryErrors = append(trusted.DiscoveryErrors, paths.DiscoveryErrors...) + trusted.SSHFiles = append(trusted.SSHFiles, pathsOutsideOverlappingRoots(paths.SSHFiles, commandAllowedRoots)...) // A command-controlled credential setting cannot revoke a root that was // deliberately granted to that same command. Dropping only overlapping // command candidates preserves all unrelated process and command denies. @@ -452,7 +465,10 @@ func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) crede } } return credentialPathOptions{ + SSHEnvironment: append([]string(nil), env...), Homes: homes, + GPGHomes: resolveCredentialOverridePaths(credentialEnvValue(env, "GNUPGHOME"), baseDirs), + BaseDirs: append([]string(nil), baseDirs...), ConfigDirs: dedupeStrings(configDirs), CloudSDKConfigDirs: dedupeStrings(cloudSDKConfigDirs), GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs), @@ -479,7 +495,10 @@ func credentialEnvValue(env []string, key string) string { } type credentialPathOptions struct { + SSHEnvironment []string Homes []string + GPGHomes []string + BaseDirs []string ConfigDirs []string CloudSDKConfigDirs []string GoogleCredentials []string @@ -514,25 +533,62 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string var carveouts []string var ensureDirs []string var dirs []string + var lexicalCandidates []string + var lexicalDirs []string + var workingDir string + if len(options.BaseDirs) > 0 { + workingDir = options.BaseDirs[0] + } + scanner := &sshDiscovery{env: options.SSHEnvironment, workingDir: workingDir} + var sshFiles []string for _, home := range options.Homes { if strings.TrimSpace(home) == "" { continue } + gnupg := filepath.Join(home, ".gnupg") homeDirs := []string{ filepath.Join(home, ".aws"), filepath.Join(home, ".azure"), + // GPG secret keyring (secring.gpg, private-keys-v1.d). Directory- + // shaped like ~/.aws so a mount-based backend masks the whole + // store, including files created later in the session (#815). + gnupg, } candidates = append(candidates, homeDirs...) dirs = append(dirs, homeDirs...) // git's credential store backend, which holds host passwords and - // personal access tokens in cleartext. Denied rather than the whole - // of ~/.ssh, because these cost nothing functionally: git reads them - // through a credential helper for authentication, not for identity, - // so a sandboxed git still works and simply cannot authenticate as - // the user. SSH key material is a harder trade and is tracked - // separately (#815). A file, so it joins candidates only — dirs - // drives directory-shaped handling (bwrap binds, carveouts). - candidates = append(candidates, filepath.Join(home, ".git-credentials")) + // personal access tokens in cleartext (#816). A file, so it joins + // candidates only — dirs drives directory-shaped handling (bwrap + // binds, carveouts). SSH private keys are denied separately as key + // material (id_*, *.pem, IdentityFile paths) rather than the whole + // of ~/.ssh, so config and known_hosts stay readable for git host + // resolution (#815). + gitCredentials := filepath.Join(home, ".git-credentials") + sshKeys := scanner.privateKeyDenyCandidates(home) + candidates = append(candidates, gitCredentials) + candidates = append(candidates, sshKeys...) + sshFiles = append(sshFiles, sshKeys...) + // Keep the lexical candidate as well as any EvalSymlinks target so a + // same-user atomic symlink retarget after profile construction still + // hits a deny on ~/.gnupg, ~/.git-credentials, and SSH private keys. + // Linux rejects selective SSH masks and mutable symlink denies; Seatbelt + // applies the lexical pathname rule at access time. + lexicalCandidates = append(lexicalCandidates, gnupg, gitCredentials) + lexicalCandidates = append(lexicalCandidates, sshKeys...) + lexicalDirs = append(lexicalDirs, gnupg) + } + for _, gnupg := range options.GPGHomes { + gnupg = strings.TrimSpace(gnupg) + if gnupg == "" { + continue + } + // GnuPG's effective home is GNUPGHOME when set, not only ~/.gnupg. + // Treat it as the same directory-shaped secret store so inherited and + // command-supplied values reach DenyReadIfExists. + candidates = append(candidates, gnupg) + dirs = append(dirs, gnupg) + lexicalCandidates = append(lexicalCandidates, gnupg) + lexicalDirs = append(lexicalDirs, gnupg) } candidates = append(candidates, options.GoogleCredentials...) candidates = append(candidates, options.NPMUserConfigs...) @@ -610,19 +666,69 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string candidates = append(candidates, tokenPath, tokenPath+".migrated") } allowRoots := normalizeProfilePaths(allowRead) - out := make([]string, 0, len(candidates)) + out := make([]string, 0, len(candidates)+len(lexicalCandidates)) for _, path := range normalizeProfilePaths(candidates) { if credentialPathReincluded(allowRoots, path) { continue } + for _, nested := range credentialNestedAllowReads(allowRoots, path) { + if normalizeCredentialCarveoutPath(nested) != "" { + carveouts = append(carveouts, nested) + } + } out = append(out, path) } + out = appendLexicalCredentialDenyPaths(out, allowRoots, lexicalCandidates) + dirList := normalizeProfilePaths(dirs) + dirList = appendLexicalCredentialDenyPaths(dirList, nil, lexicalDirs) return credentialDenyPaths{ - Paths: out, - Carveouts: credentialCarveoutPaths(out, carveouts), - EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)), - Dirs: credentialRetainedDirs(out, normalizeProfilePaths(dirs)), + DiscoveryErrors: scanner.errors, + SSHFiles: credentialRetainedDirs(out, normalizeProfilePaths(sshFiles)), + Paths: out, + Carveouts: credentialCarveoutPaths(out, carveouts), + EnsureDirs: credentialRetainedDirs(out, normalizeProfilePaths(ensureDirs)), + Dirs: credentialRetainedDirs(out, dirList), + } +} + +// appendLexicalCredentialDenyPaths adds the pre-EvalSymlinks spelling of each +// candidate when a symlink is in the resolution chain. normalizeProfilePath +// replaces a symlink with its target, so omitting the lexical path would let +// a later atomic retarget of the same pathname escape the deny list. +// String inequality alone is not enough: Windows EvalSymlinks rewrites +// regular files to 8.3 short names (RUNNER~1 vs runneradmin) even when no +// symlink is involved, and dual-adding those spellings breaks exact bwrap +// dest sequences. +func appendLexicalCredentialDenyPaths(out, allowRoots, candidates []string) []string { + if len(candidates) == 0 { + return out + } + seen := make(map[string]struct{}, len(out)) + for _, path := range out { + seen[path] = struct{}{} + } + for _, path := range candidates { + lexical := normalizeProfilePathLexically(path) + if lexical == "" { + continue + } + if _, ok := seen[lexical]; ok { + continue + } + if credentialPathReincluded(allowRoots, lexical) { + continue + } + resolved := normalizeProfilePath(path) + if resolved != "" && credentialPathReincluded(allowRoots, resolved) { + continue + } + if resolved != "" && resolved != lexical && !pathResolutionInvolvesSymlink(path) { + continue + } + seen[lexical] = struct{}{} + out = append(out, lexical) } + return out } // credentialTokenStorePaths returns the deny entries for one token-store path: @@ -674,7 +780,7 @@ func pathsOutsideRoots(paths []string, roots []string) []string { } out := make([]string, 0, len(paths)) for _, path := range paths { - if credentialPathReincluded(roots, path) { + if credentialPathCoveredByCanonicalRoots(roots, path) { continue } out = append(out, path) @@ -693,7 +799,7 @@ func pathsOutsideOverlappingRoots(paths []string, roots []string) []string { for _, path := range paths { overlaps := false for _, root := range roots { - if pathWithinRoot(root, path) || pathWithinRoot(path, root) { + if pathWithinRootCanonical(root, path) || pathWithinRootCanonical(path, root) { overlaps = true break } @@ -714,6 +820,53 @@ func credentialPathReincluded(allowRoots []string, path string) bool { return false } +// credentialNestedAllowReads returns allowRead paths that sit strictly inside +// path — a nested grant under a credential directory. Containment is canonical +// so a lexical ~/.gnupg symlink is recognized as the parent of a nested +// allowRead that lives under the symlink target. pathWithinRoot on the lexical +// spelling would miss that pair, keep the lexical dir deny, and let Seatbelt +// and bwrap expand it onto the canonical store. +func credentialNestedAllowReads(allowRoots []string, path string) []string { + if path == "" || len(allowRoots) == 0 { + return nil + } + var out []string + for _, allow := range allowRoots { + if allow == path { + continue + } + if pathWithinRootCanonical(path, allow) && !pathWithinRootCanonical(allow, path) { + out = append(out, allow) + } + } + return out +} + +// pathWithinRootCanonical compares after EvalSymlinks so a lexical /var/... +// candidate is recognized as lying under a canonical /private/var/... root. +// Overlap and allow checks use this identity; backends emit lexical symlink +// dests separately via unreadableEnforcementPath. +func pathWithinRootCanonical(root, candidate string) bool { + nr := normalizeProfilePath(root) + if nr == "" { + nr = root + } + nc := normalizeProfilePath(candidate) + if nc == "" { + nc = candidate + } + return pathWithinRoot(nr, nc) +} + +func credentialPathCoveredByCanonicalRoots(roots []string, path string) bool { + for _, root := range roots { + if pathWithinRootCanonical(root, path) { + return true + } + } + return false +} + // credentialCarveoutPaths keeps only the carveouts that sit inside a path that // is actually denied, so an AllowRead opt-out that removed the deny does not // leave a stray allow-back rule behind. @@ -748,10 +901,10 @@ func normalizeCredentialCarveoutPath(entry string) string { return "" } // A missing fixed subtree may be installed later by trusted host code, but - // an existing entry must be a real directory. In particular, never turn a - // plugins symlink into an allow rule for its credential-file target. + // an existing entry must be a real directory or regular file. Never turn a + // symlink into an allow rule for its credential target. if info, err := os.Lstat(carveout); err == nil { - if !info.IsDir() { + if info.Mode()&os.ModeSymlink != 0 { return "" } } else if !os.IsNotExist(err) { @@ -786,6 +939,7 @@ func credentialRetainedDirs(denied []string, dirs []string) []string { // Children inside a retained carveout stay explicit denies, because the // carveout re-allows that subtree. func finalizeCredentialDenyPaths(credentials credentialDenyPaths, userDenyRead []string) credentialDenyPaths { + credentials.SSHFiles = credentialRetainedFiles(credentials.SSHFiles, userDenyRead, credentials.EnsureDirs, credentials.Carveouts) credentials.Paths = pathsOutsideRoots(credentials.Paths, userDenyRead) credentials.Carveouts = pathsOutsideOverlappingRoots(credentials.Carveouts, userDenyRead) credentials.Dirs = credentialRetainedDirs(credentials.Paths, credentials.Dirs) @@ -983,6 +1137,9 @@ func normalizeProfilePath(entry string) string { if absolute == "" { return "" } + if canonical, _, ok := lookupTestCredentialPathAlias(absolute); ok { + return canonical + } if resolved, err := filepath.EvalSymlinks(absolute); err == nil { return resolved } @@ -1020,6 +1177,107 @@ func normalizeCredentialFinalPath(path string) string { return filepath.Join(parent, filepath.Base(filepath.Clean(path))) } +// unreadableEnforcementPath is the dest a bwrap bind or Seatbelt rule should +// use for path. Dual-emitting the pre-EvalSymlinks spelling is only useful +// when a symlink is in the resolution chain (a leaf symlink, an intermediate +// directory symlink such as ~/.ssh, or macOS /var -> /private/var). In that +// case keep the lexical pathname so a later atomic retarget still hits the +// same dest. Other paths keep EvalSymlinks so Windows 8.3 rewrites of regular +// files are not treated as a second dest. Overlap and allow checks use +// canonical identity via pathWithinRootCanonical, not this. +func unreadableEnforcementPath(path string) string { + lexical := normalizeProfilePathLexically(path) + if lexical == "" { + return "" + } + resolved := normalizeProfilePath(path) + if resolved == "" { + return lexical + } + if resolved == lexical || !pathResolutionInvolvesSymlink(path) { + return resolved + } + return lexical +} + +// unreadableEnforcementPaths preserves lexical identity only when a symlink +// is in the resolution chain, including intermediate directory symlinks +// (for example ~/.ssh -> elsewhere with a regular key file inside). A later +// retarget of that directory would otherwise expose the key through the +// original pathname. Non-symlink paths stay canonical, even when EvalSymlinks +// rewrites the spelling (Windows 8.3 short names). +func unreadableEnforcementPaths(paths []string) []string { + if len(paths) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(paths)*2) + add := func(p string) { + if p == "" { + return + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + for _, path := range paths { + lexical := normalizeProfilePathLexically(path) + if lexical == "" { + continue + } + canonical := normalizeProfilePath(path) + if canonical == "" { + add(lexical) + continue + } + if lexical != canonical && pathResolutionInvolvesSymlink(path) { + add(lexical) + } + add(canonical) + } + return out +} + +// testCredentialPathAlias remaps a lexically normalized path for tests so +// both the EvalSymlinks (canonical) deny entry and the lexical extra can be +// pinned without creating OS symlinks. Production leaves it nil. +var testCredentialPathAlias func(lexical string) (canonical string, involvesSymlink bool, ok bool) + +func lookupTestCredentialPathAlias(lexical string) (canonical string, involvesSymlink bool, ok bool) { + if testCredentialPathAlias == nil || lexical == "" { + return "", false, false + } + return testCredentialPathAlias(lexical) +} + +// pathResolutionInvolvesSymlink reports whether Lstat of path or an ancestor +// is a symlink. Dual-adding lexical + EvalSymlinks target is only valid in +// that case: macOS /var -> /private/var and a real ~/.ssh directory symlink +// need both spellings, but Windows EvalSymlinks 8.3 short names of regular +// files must not dual-add. +func pathResolutionInvolvesSymlink(path string) bool { + current := normalizeProfilePathLexically(path) + if current == "" { + return false + } + if _, involves, ok := lookupTestCredentialPathAlias(current); ok { + return involves + } + for { + info, err := os.Lstat(current) + if err == nil && info.Mode().Type() == os.ModeSymlink { + return true + } + parent := filepath.Dir(current) + if parent == current { + return false + } + current = parent + } +} + // normalizeProfilePathLexically expands and absolutizes a profile path without // resolving symlinks. Credential carveouts use it so their fixed lexical name // can never become an allow rule for a symlink target. diff --git a/internal/sandbox/reentrancy_test.go b/internal/sandbox/reentrancy_test.go index 9e0b0bcb6..36e93f3e2 100644 --- a/internal/sandbox/reentrancy_test.go +++ b/internal/sandbox/reentrancy_test.go @@ -100,13 +100,14 @@ func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) { root := t.TempDir() engine := NewEngine(EngineOptions{ WorkspaceRoot: root, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}, }) plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root}) if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) if !plan.Wrapped || plan.Name != "/usr/bin/zero-linux-sandbox" { t.Fatalf("expected a wrapped Linux helper plan, got wrapped=%v name=%q", plan.Wrapped, plan.Name) } diff --git a/internal/sandbox/request_permissions_test.go b/internal/sandbox/request_permissions_test.go index 330cf69bc..e90cfdc31 100644 --- a/internal/sandbox/request_permissions_test.go +++ b/internal/sandbox/request_permissions_test.go @@ -67,7 +67,7 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) { workspace := t.TempDir() engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Backend: Backend{ Name: BackendLinuxBwrap, Available: true, @@ -105,6 +105,7 @@ func TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan with network grant: %v", err) } + t.Cleanup(plan.Cleanup) if plan.Policy.Network != NetworkAllow || plan.PermissionProfile.Network.Mode != NetworkAllow { t.Fatalf("network turn grant should build a network-allow command plan, got policy=%s profile=%s", plan.Policy.Network, plan.PermissionProfile.Network.Mode) } diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 8528e7e82..55a2c7e10 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -229,6 +229,9 @@ func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy if execRequest.EnforcementLevel == EnforcementDisabled || execRequest.EnforcementLevel == EnforcementDegraded || execRequest.TargetBackend == BackendNone || !execRequest.RequiresPlatformSandbox { return withSandboxExecutionMetadata(directCommandPlan(spec, backend, policy, workspaceRoot), execRequest), nil } + if problems := execRequest.PermissionProfile.FileSystem.CredentialDiscoveryErrors; len(problems) > 0 { + return CommandPlan{}, fmt.Errorf("cannot guarantee credential protection: %s", strings.Join(problems, "; ")) + } switch backend.Name { case BackendLinuxBwrap: if backend.Available && backend.Executable != "" { @@ -900,7 +903,7 @@ func denyWriteRulesFromPaths(paths []string) []string { } func denySeatbeltPathRules(action string, paths []string) []string { - return denySeatbeltNormalizedPathRules(action, normalizeProfilePaths(paths)) + return denySeatbeltNormalizedPathRules(action, unreadableEnforcementPaths(paths)) } func denySeatbeltNormalizedPathRules(action string, paths []string) []string { diff --git a/internal/sandbox/runner_linux_integration_test.go b/internal/sandbox/runner_linux_integration_test.go index 63e22ff71..1aa7ba9e0 100644 --- a/internal/sandbox/runner_linux_integration_test.go +++ b/internal/sandbox/runner_linux_integration_test.go @@ -62,8 +62,8 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { t.Fatalf("Mkdir blocked: %v", err) } - policy := DefaultPolicy() - policy.DenyRead = []string{secretDir} + policy := testPolicyWithSSHDirectoryDeny(t, credentialHome) + policy.DenyRead = append(policy.DenyRead, secretDir) policy.DenyWrite = []string{blockedDir} engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: backend}) output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{ @@ -93,7 +93,9 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { t.Run("fresh home and non-git workspace launch", func(t *testing.T) { freshRoot := t.TempDir() freshHome := t.TempDir() - freshEngine := NewEngine(EngineOptions{WorkspaceRoot: freshRoot, Policy: DefaultPolicy(), Backend: backend}) + t.Setenv("HOME", freshHome) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(freshHome, ".config")) + freshEngine := NewEngine(EngineOptions{WorkspaceRoot: freshRoot, Policy: testPolicyWithSSHDirectoryDeny(t, freshHome), Backend: backend}) output, runErr := runLinuxSandboxSmokeCommand(t, freshEngine, CommandSpec{ Name: "/bin/sh", Args: []string{"-c", "echo ok > launched"}, @@ -109,7 +111,11 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) { commandRoot := filepath.Join(tempDirOutsideDefaultTemp(t), "missing-command-home") commandConfig := filepath.Join(commandRoot, "config") launched := filepath.Join(root, "command-credential-root-launched") - engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend}) + missingPolicy := testPolicyWithSSHDirectoryDeny(t, credentialHome) + // Exercise the absent credential-directory check without the independent + // selective SSH refusal rejecting this command first. No path is created. + missingPolicy.AllowRead = append(missingPolicy.AllowRead, filepath.Join(commandRoot, ".ssh")) + engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: missingPolicy, Backend: backend}) _, err := engine.BuildCommandPlan(CommandSpec{ Name: "/bin/sh", Args: []string{"-c", "echo launched > " + shellQuote(launched)}, diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index e707e2397..9cb751d99 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -20,7 +20,7 @@ func TestBuildCommandPlanWrapsLinuxHelper(t *testing.T) { resolvedNested := resolvedTestPath(t, nested) engine := NewEngine(EngineOptions{ WorkspaceRoot: root, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Backend: Backend{ Name: BackendLinuxBwrap, Available: true, @@ -38,6 +38,7 @@ func TestBuildCommandPlanWrapsLinuxHelper(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) if !plan.Wrapped || plan.Name != "/usr/bin/zero-linux-sandbox" || plan.Backend.Name != BackendLinuxBwrap { t.Fatalf("plan backend = %#v, want wrapped Linux helper", plan) @@ -758,7 +759,7 @@ func TestLinuxHelperPlanCarriesExtraWriteRoots(t *testing.T) { } engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Scope: scope, Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}, }) @@ -766,6 +767,7 @@ func TestLinuxHelperPlanCarriesExtraWriteRoots(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) config, err := ParseLinuxSandboxHelperArgs(plan.Args) if err != nil { t.Fatalf("ParseLinuxSandboxHelperArgs: %v", err) @@ -806,7 +808,7 @@ func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) { } engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Scope: scope, Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}, }) @@ -815,6 +817,7 @@ func TestLinuxHelperPlanPreservesRealExtraRootCwd(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) if filepath.Clean(plan.SandboxDir) != filepath.Clean(resolvedExtra) { t.Fatalf("SandboxDir=%q want real extra-root path %q", plan.SandboxDir, resolvedExtra) } @@ -880,7 +883,7 @@ func TestEngineScrubsConfiguredSensitiveEnvKeys(t *testing.T) { workspace := t.TempDir() engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t), Backend: Backend{Name: BackendLinuxBwrap, Available: true, Executable: "/usr/bin/zero-linux-sandbox"}, SensitiveEnvKeys: []string{"COMPANY_LLM_SECRET"}, }) @@ -896,6 +899,7 @@ func TestEngineScrubsConfiguredSensitiveEnvKeys(t *testing.T) { if err != nil { t.Fatalf("BuildCommandPlan: %v", err) } + t.Cleanup(plan.Cleanup) for _, entry := range plan.Env { key, _, _ := strings.Cut(entry, "=") if strings.EqualFold(key, "COMPANY_LLM_SECRET") || strings.EqualFold(key, "ZERO_OAUTH_CUSTOM_CLIENT_SECRET") { diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go index b707f67f2..0fb2c95ae 100644 --- a/internal/sandbox/runtime_state_test.go +++ b/internal/sandbox/runtime_state_test.go @@ -277,13 +277,14 @@ func withoutGitEnvironmentOverrides(env []string) []string { func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { workspace := t.TempDir() cacheRoot := t.TempDir() - t.Setenv("HOME", filepath.Join(t.TempDir(), "home")) + home := filepath.Join(t.TempDir(), "home") + t.Setenv("HOME", home) original := sandboxUserCacheDir sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil } t.Cleanup(func() { sandboxUserCacheDir = original }) engine := NewEngine(EngineOptions{ WorkspaceRoot: workspace, - Policy: DefaultPolicy(), + Policy: testPolicyWithSSHDirectoryDeny(t, home), Backend: Backend{ Name: BackendLinuxBwrap, Available: true, @@ -310,8 +311,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) { } else if !inUse { t.Fatal("command plan runtime must be marked in use") } - if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") { - t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME")) + if got := envListValue(plan.Env, "HOME", ""); got != home { + t.Fatalf("HOME = %q, want caller home %q", got, home) } foundWriteRoot := false for _, root := range plan.PermissionProfile.FileSystem.WriteRoots { diff --git a/internal/sandbox/ssh_discovery_limits_test.go b/internal/sandbox/ssh_discovery_limits_test.go new file mode 100644 index 000000000..ef034fdac --- /dev/null +++ b/internal/sandbox/ssh_discovery_limits_test.go @@ -0,0 +1,250 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func sshTestBwrapOptions(t *testing.T, profile PermissionProfile) LinuxSandboxBwrapOptions { + t.Helper() + dir := t.TempDir() + helper, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return LinuxSandboxBwrapOptions{HelperPath: helper, Config: LinuxSandboxHelperConfig{ + PermissionProfile: profile, SandboxPolicyCWD: dir, CommandCWD: dir, Command: []string{"true"}, + }} +} + +func assertLinuxCredentialPlanRejected(t *testing.T, profile PermissionProfile) { + t.Helper() + _, err := BuildLinuxSandboxBwrapArgs(sshTestBwrapOptions(t, profile)) + if err == nil || !strings.Contains(err.Error(), "mutable symlink") { + t.Fatalf("unsafe symlink plan did not reject execution: %v", err) + } + plan := buildLinuxBwrapFilesystemPlan(profile) + if plan.Err == nil || len(plan.Args) != 0 { + t.Fatalf("filesystem planner returned an executable partial plan: %#v", plan) + } +} + +func TestSSHDiscoveryLimitsRejectExecution(t *testing.T) { + for _, kind := range []string{"config Include match", "config size"} { + t.Run(kind, func(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + switch kind { + case "config Include match": + mustWriteFile(t, filepath.Join(sshDir, "config"), "Include includes/*\n") + for i := 0; i <= sshIncludeMatchCap; i++ { + mustWriteFile(t, filepath.Join(sshDir, "includes", fmt.Sprintf("%03d", i)), "IdentityFile ~/relocated-key\n") + } + case "config size": + mustWriteFile(t, filepath.Join(sshDir, "config"), strings.Repeat("#", sshConfigMaxBytes+1)) + } + credentials := credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, nil) + profile := PermissionProfile{FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + CredentialDiscoveryErrors: credentials.DiscoveryErrors, + }} + _, err := BuildLinuxSandboxBwrapArgs(sshTestBwrapOptions(t, profile)) + if err == nil || !strings.Contains(err.Error(), kind+" limit exceeded") { + t.Fatalf("incomplete discovery did not reject execution: %v", err) + } + }) + } +} + +func TestSSHDiscoveryWalksLargeAndDeepDirectories(t *testing.T) { + for _, kind := range []string{"large", "deep"} { + t.Run(kind, func(t *testing.T) { + sshDir := filepath.Join(t.TempDir(), ".ssh") + dir := sshDir + if kind == "large" { + for i := range 600 { + mustWriteFile(t, filepath.Join(dir, fmt.Sprintf("public-%03d", i)), "public data") + } + } else { + for range 12 { + dir = filepath.Join(dir, "d") + } + } + key := filepath.Join(dir, "work-key") + mustWriteFile(t, key, sshPrivateKeyFixture()) + scanner := &sshDiscovery{} + keys := scanner.walkPrivateKeyFiles(sshDir) + if len(scanner.errors) != 0 || len(keys) != 1 || keys[0] != key { + t.Fatalf("discovery = %v, errors = %v; want the nested key", keys, scanner.errors) + } + }) + } +} + +func TestLinuxExplicitAbsentDenyRejectsExecution(t *testing.T) { + path := filepath.Join(t.TempDir(), "future-secret") + profile := PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, DenyRead: []string{path}}} + _, err := BuildLinuxSandboxBwrapArgs(sshTestBwrapOptions(t, profile)) + if err == nil || !strings.Contains(err.Error(), "cannot guarantee an explicit deny") { + t.Fatalf("absent explicit deny did not reject execution: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("planning mutated the denied path: %v", err) + } +} + +func TestLinuxSelectiveSSHProtectionRejectsExecution(t *testing.T) { + home := t.TempDir() + key := filepath.Join(home, ".ssh", "custom-key") + mustWriteFile(t, key, sshPrivateKeyFixture()) + credentials := credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, nil) + profile := PermissionProfile{FileSystem: FileSystemPolicy{Kind: FileSystemRestricted, SSHDenyReadFiles: credentials.SSHFiles}} + if err := validateLinuxBwrapPermissionProfile(profile); err == nil || !strings.Contains(err.Error(), "selective SSH key protection") { + t.Fatalf("selective key mask was accepted: %v", err) + } + credentials = finalizeCredentialDenyPaths(credentials, []string{normalizeProfilePath(filepath.Dir(key))}) + if len(credentials.SSHFiles) != 0 { + t.Fatalf("whole-directory deny did not cover SSH files: %v", credentials.SSHFiles) + } +} + +func TestSSHAllowReadDirectoryKeepsExternalKeyDeny(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + key := filepath.Join(home, "keys", "work") + mustWriteFile(t, key, sshPrivateKeyFixture()) + mustWriteFile(t, filepath.Join(sshDir, "config"), "IdentityFile ~/keys/work\n") + credentials := credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, []string{sshDir}) + if !denyCovered(credentials.Paths, key) { + t.Fatal("allowing the SSH directory also exposed a referenced key outside it") + } +} + +func TestLinuxAbsentSSHKeyRefusesCommandBeforeCreation(t *testing.T) { + for _, kind := range []string{"absent-directory", "empty-directory", "configured-external-key"} { + t.Run(kind, func(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + key := filepath.Join(sshDir, "id_ed25519") + var allowRead []string + if kind == "empty-directory" { + if err := os.Mkdir(sshDir, 0700); err != nil { + t.Fatal(err) + } + } + if kind == "configured-external-key" { + key = filepath.Join(home, "keys", "future-key") + mustWriteFile(t, filepath.Join(sshDir, "config"), "IdentityFile ~/keys/future-key\n") + // Isolate the external candidate from the conventional key denies. + allowRead = []string{sshDir} + } + credentials := credentialDenyReadPathsIn(credentialPathOptions{Homes: []string{home}}, allowRead) + profile := PermissionProfile{FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, ReadRoots: []string{"/"}, + DenyReadIfExists: credentials.Paths, SSHDenyReadFiles: credentials.SSHFiles, + }} + // Fail closed before launch: there must be no running sandbox in which + // a trusted host writer can later make this key readable. + for _, created := range []bool{false, true} { + if created { + mustWriteFile(t, key, sshPrivateKeyFixture()) + } + args, err := BuildLinuxSandboxBwrapArgs(sshTestBwrapOptions(t, profile)) + if err == nil || !strings.Contains(err.Error(), "selective SSH key protection") || len(args) != 0 { + t.Errorf("profile constructed before key creation allowed command planning (created=%v): %v", created, err) + } + } + // An explicit directory deny covers both present and future keys. + credentials = finalizeCredentialDenyPaths(credentials, []string{normalizeProfilePath(filepath.Dir(key))}) + if len(credentials.SSHFiles) != 0 { + t.Errorf("containing-directory deny did not cover future external key: %v", credentials.SSHFiles) + } + }) + } +} + +func TestSSHDiscoverySymlinkDirectoryBounds(t *testing.T) { + t.Run("symlink directory limit exceeded", func(t *testing.T) { + sshDir := filepath.Join(t.TempDir(), ".ssh") + mustWriteFile(t, filepath.Join(sshDir, "known_hosts"), "example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...") + external := t.TempDir() + curr := external + for i := 0; i <= sshSymlinkMaxDirs; i++ { + curr = filepath.Join(curr, fmt.Sprintf("dir-%03d", i)) + if err := os.Mkdir(curr, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil { + t.Fatal(err) + } + scanner := &sshDiscovery{} + _ = scanner.walkPrivateKeyFiles(sshDir) + if len(scanner.errors) == 0 { + t.Fatal("expected discovery error for symlink directory limit exceeded, got none") + } + var matched bool + for _, e := range scanner.errors { + if strings.Contains(e, "symlink directory limit exceeded") { + matched = true + break + } + } + if !matched { + t.Fatalf("expected symlink directory limit exceeded, got %v", scanner.errors) + } + }) + + t.Run("symlink entry limit exceeded", func(t *testing.T) { + sshDir := filepath.Join(t.TempDir(), ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + t.Fatal(err) + } + external := t.TempDir() + for i := 0; i <= sshSymlinkMaxEntries; i++ { + mustWriteFile(t, filepath.Join(external, fmt.Sprintf("file-%04d", i)), "data") + } + if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil { + t.Fatal(err) + } + scanner := &sshDiscovery{} + _ = scanner.walkPrivateKeyFiles(sshDir) + if len(scanner.errors) == 0 { + t.Fatal("expected discovery error for symlink entry limit exceeded, got none") + } + var matched bool + for _, e := range scanner.errors { + if strings.Contains(e, "symlink entry limit exceeded") { + matched = true + break + } + } + if !matched { + t.Fatalf("expected symlink entry limit exceeded, got %v", scanner.errors) + } + }) + + t.Run("symlink within budget succeeds", func(t *testing.T) { + sshDir := filepath.Join(t.TempDir(), ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + t.Fatal(err) + } + external := t.TempDir() + keyFile := filepath.Join(external, "custom_key") + mustWriteFile(t, keyFile, sshPrivateKeyFixture()) + if err := os.Symlink(external, filepath.Join(sshDir, "link")); err != nil { + t.Fatal(err) + } + scanner := &sshDiscovery{} + keys := scanner.walkPrivateKeyFiles(sshDir) + if len(scanner.errors) != 0 { + t.Fatalf("unexpected discovery errors: %v", scanner.errors) + } + if len(keys) == 0 { + t.Fatal("expected discovered key in symlinked directory, got none") + } + }) +} diff --git a/internal/sandbox/ssh_gpg_deny_test.go b/internal/sandbox/ssh_gpg_deny_test.go new file mode 100644 index 000000000..132b40b97 --- /dev/null +++ b/internal/sandbox/ssh_gpg_deny_test.go @@ -0,0 +1,1733 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func denyCovered(denied []string, target string) bool { + norm := normalizeProfilePath(target) + if norm == "" { + norm = filepath.Clean(target) + } + for _, entry := range denied { + normEntry := normalizeProfilePath(entry) + if normEntry == "" { + normEntry = filepath.Clean(entry) + } + if normEntry == norm || pathWithinRoot(normEntry, norm) { + return true + } + } + return false +} + +func denyListedExact(denied []string, target string) bool { + for _, entry := range denied { + if entry == target { + return true + } + } + return false +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks not supported or permitted in this environment: %v", err) + } +} + +func sshPrivateKeyFixture() string { + return strings.Join([]string{"-----BEGIN OPENSSH", " PRIVATE KEY-----\nfixture\n"}, "") +} + +func puttyPrivateKeyFixture() string { + return strings.Join([]string{"PuTTY-User-Key", "-File-2: ssh-rsa\nEncryption: none\n"}, "") +} + +func sshGPGDenied(t *testing.T, home string, allowRead []string) []string { + t.Helper() + return credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, allowRead).Paths +} + +func sshGPGNormalizationHome() (home, sshDir string) { + if runtime.GOOS == "windows" { + home = `C:\Users\zero-sandbox` + } else { + home = "/home/zero-sandbox" + } + return home, filepath.Join(home, ".ssh") +} + +// Option 2 of #815: deny SSH private key material and the GPG keyring, not +// the whole of ~/.ssh. git credential files from #816 must stay denied. +func TestCredentialDenyReadPathsDeniesSSHKeyMaterialNotDirectory(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + idEd := filepath.Join(sshDir, "id_ed25519") + idPub := filepath.Join(sshDir, "id_ed25519.pub") + config := filepath.Join(sshDir, "config") + knownHosts := filepath.Join(sshDir, "known_hosts") + fooPEM := filepath.Join(sshDir, "foo.pem") + rsaPEM := filepath.Join(sshDir, "id_rsa.pem") + secring := filepath.Join(home, ".gnupg", "secring.gpg") + privateKey := filepath.Join(home, ".gnupg", "private-keys-v1.d", "keygrip.key") + gitCredentials := filepath.Join(home, ".git-credentials") + xdgCredentials := filepath.Join(home, ".config", "git", "credentials") + + // Path-based denials (id_*, *.pem, ~/.gnupg, credential stores). Empty or + // obviously-fake bodies so scanners do not treat fixtures as live keys. + mustWriteFile(t, idEd, "") + mustWriteFile(t, idPub, "ssh-ed25519 AAAA public\n") + mustWriteFile(t, config, "Host *\n") + mustWriteFile(t, knownHosts, "github.com ssh-ed25519 AAAA\n") + mustWriteFile(t, fooPEM, "") + mustWriteFile(t, rsaPEM, "") + mustWriteFile(t, secring, "fake-secring") + mustWriteFile(t, privateKey, "fake-keygrip") + mustWriteFile(t, gitCredentials, "https://user:token@github.com") + mustWriteFile(t, xdgCredentials, "https://user:token@github.com") + + denied := sshGPGDenied(t, home, nil) + + if !denyCovered(denied, idEd) { + t.Fatalf("~/.ssh/id_ed25519 is readable; deny list = %v", denied) + } + if denyCovered(denied, idPub) { + t.Fatalf("~/.ssh/id_ed25519.pub was denied; public keys must stay readable") + } + if denyCovered(denied, config) { + t.Fatalf("~/.ssh/config was denied; git host resolution would break") + } + if denyCovered(denied, knownHosts) { + t.Fatalf("~/.ssh/known_hosts was denied; git host resolution would break") + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale; option 2 keeps the directory readable") + } + if !denyCovered(denied, fooPEM) { + t.Fatalf("~/.ssh/foo.pem is readable; deny list = %v", denied) + } + if !denyCovered(denied, rsaPEM) { + t.Fatalf("~/.ssh/id_rsa.pem is readable; deny list = %v", denied) + } + if !denyCovered(denied, secring) { + t.Fatalf("~/.gnupg/secring.gpg is readable; deny list = %v", denied) + } + if !denyCovered(denied, privateKey) { + t.Fatalf("~/.gnupg/private-keys-v1.d file is readable; deny list = %v", denied) + } + if !denyCovered(denied, gitCredentials) { + t.Fatalf("~/.git-credentials is readable after #815 SSH work; deny list = %v", denied) + } + if !denyCovered(denied, xdgCredentials) { + t.Fatalf("~/.config/git/credentials is readable after #815 SSH work; deny list = %v", denied) + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFileOutsideSSH(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + workKey := filepath.Join(home, "keys", "work_ed25519") + mustWriteFile(t, workKey, "") + mustWriteFile(t, workKey+".pub", "ssh-ed25519 AAAA work\n") + mustWriteFile(t, filepath.Join(sshDir, "config"), `Host work + IdentityFile ~/keys/work_ed25519 + CertificateFile ~/keys/work_ed25519.pub + UserKnownHostsFile ~/.ssh/known_hosts +`) + mustWriteFile(t, filepath.Join(sshDir, "known_hosts"), "example.com ssh-ed25519 AAAA\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile ~/keys/work_ed25519 is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(sshDir, "known_hosts")) { + t.Fatalf("known_hosts was denied because a path-valued directive pointed at it") + } + if denyCovered(denied, workKey+".pub") { + t.Fatalf("CertificateFile *.pub was denied; option 2 keeps public keys readable") + } + if denyCovered(denied, filepath.Join(sshDir, "config")) { + t.Fatalf("~/.ssh/config was denied") + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFilePercentD(t *testing.T) { + home := t.TempDir() + workKey := filepath.Join(home, "keys", "work_ed25519") + mustWriteFile(t, workKey, "") + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile %d/keys/work_ed25519\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile %%d/keys/work_ed25519 is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsFollowsSSHConfigIncludeAndStopsCycles(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + includedKey := filepath.Join(home, "keys", "included_ed25519") + cycleKey := filepath.Join(home, "keys", "cycle_ed25519") + mustWriteFile(t, includedKey, "") + mustWriteFile(t, cycleKey, "") + mustWriteFile(t, filepath.Join(sshDir, "config"), "Include extra_config\nInclude cycle_a\nInclude missing_include\n") + mustWriteFile(t, filepath.Join(sshDir, "extra_config"), "IdentityFile ~/keys/included_ed25519\n") + mustWriteFile(t, filepath.Join(sshDir, "cycle_a"), "Include cycle_b\n") + mustWriteFile(t, filepath.Join(sshDir, "cycle_b"), "Include cycle_a\nIdentityFile ~/keys/cycle_ed25519\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, includedKey) { + t.Fatalf("Include IdentityFile is readable; deny list = %v", denied) + } + if !denyCovered(denied, cycleKey) { + t.Fatalf("cyclic Include IdentityFile is readable; deny list = %v", denied) + } +} + +func TestSSHKeyDenyYieldsToExplicitAllowRead(t *testing.T) { + home := t.TempDir() + idEd := filepath.Join(home, ".ssh", "id_ed25519") + mustWriteFile(t, idEd, "") + target := normalizeProfilePath(idEd) + listed := func(entries []string) bool { + for _, entry := range entries { + if entry == target { + return true + } + } + return false + } + + if !listed(sshGPGDenied(t, home, nil)) { + t.Fatalf("~/.ssh/id_ed25519 is not denied without an allowRead; nothing for the grant to override") + } + if listed(sshGPGDenied(t, home, []string{home})) { + t.Fatalf("explicit allowRead %q did not re-include the SSH private key", home) + } +} + +// Path-sensitive SSH/GPG handling needs a non-Linux case (or a hermetic fake +// of the same normalization). Token expansion is GOOS-independent, so a +// Windows home spelling exercises %d without touching the host filesystem. +func TestExpandSSHConfigPathTokensWindowsStyleHome(t *testing.T) { + home := `C:\Users\zero-sandbox` + got, ok := expandSSHConfigPathTokens(`%d\keys\work_ed25519`, home) + if !ok { + t.Fatalf("supported %%d token was rejected") + } + want := `C:\Users\zero-sandbox\keys\work_ed25519` + if got != want { + t.Fatalf("Windows-style %%d expansion = %q, want %q", got, want) + } + got, ok = expandSSHConfigPathTokens("%d/keys/work_ed25519", home) + if !ok { + t.Fatalf("supported %%d token with slash was rejected") + } + want = `C:\Users\zero-sandbox/keys/work_ed25519` + if got != want { + t.Fatalf("Windows-style %%d with slash = %q, want %q", got, want) + } + if _, ok := expandSSHConfigPathTokens("%h/keys/work_ed25519", home); ok { + t.Fatalf("unsupported %%h token must be rejected") + } + got, ok = expandSSHConfigPathTokens("id%%ed25519", home) + if !ok || got != "id%ed25519" { + t.Fatalf("%% -> %% expansion = %q ok=%v, want %q", got, ok, "id%ed25519") + } +} + +func TestExpandSSHConfigPathPercentDUsesSuppliedHome(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + got := expandSSHConfigPath("%d/keys/work_ed25519", home, sshDir) + want := filepath.Join(home, "keys", "work_ed25519") + if got != want { + t.Fatalf("expandSSHConfigPath(%%d) = %q, want %q", got, want) + } + if expandSSHConfigPath("%h/keys/work_ed25519", home, sshDir) != "" { + t.Fatalf("unsupported %%h token must be dropped") + } + if expandSSHConfigPath("%d/%h/keys", home, sshDir) != "" { + t.Fatalf("remaining unsupported token after %%d must be dropped") + } + got = expandSSHConfigPath("id%%ed25519", home, sshDir) + want = filepath.Join(sshDir, "id%ed25519") + if got != want { + t.Fatalf("literal %% expansion = %q, want %q", got, want) + } + if sshShouldDenyReferencedPath(sshDir, home, sshDir) { + t.Fatalf("~/.ssh was denied wholesale under the fake home") + } + idEd := filepath.Join(sshDir, "id_ed25519") + if !sshShouldDenyReferencedPath(idEd, home, sshDir) { + t.Fatalf("well-known SSH key under fake home was not a deny candidate") + } + gnupg := filepath.Join(home, ".gnupg") + gitCredentials := filepath.Join(home, ".git-credentials") + if filepath.Base(gnupg) != ".gnupg" || filepath.Base(gitCredentials) != ".git-credentials" { + t.Fatalf("GPG/git credential join lost the host separator; gnupg=%q git=%q", gnupg, gitCredentials) + } +} + +func TestCredentialDenyReadPathsKeepsLexicalSymlinkCandidates(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + realDir := t.TempDir() + + gnupgLink := filepath.Join(home, ".gnupg") + gnupgTarget := filepath.Join(realDir, "gnupg-store") + if err := os.MkdirAll(gnupgTarget, 0o700); err != nil { + t.Fatal(err) + } + mustSymlink(t, gnupgTarget, gnupgLink) + + gitLink := filepath.Join(home, ".git-credentials") + gitTarget := filepath.Join(realDir, "git-credentials") + mustWriteFile(t, gitTarget, "x") + mustSymlink(t, gitTarget, gitLink) + + sshLink := filepath.Join(home, ".ssh", "id_ed25519") + sshTarget := filepath.Join(realDir, "id_ed25519") + mustWriteFile(t, sshTarget, "") + mustSymlink(t, sshTarget, sshLink) + + denied := sshGPGDenied(t, home, nil) + for _, candidate := range []string{gnupgLink, gitLink, sshLink} { + lexical := normalizeProfilePathLexically(candidate) + if !denyListedExact(denied, lexical) { + t.Fatalf("lexical candidate %q missing from deny list %v", lexical, denied) + } + resolved := normalizeProfilePath(candidate) + if resolved != "" && resolved != lexical && !denyListedExact(denied, resolved) { + t.Fatalf("resolved target %q missing from deny list %v", resolved, denied) + } + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesNestedSSHPrivateKeys(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + nestedKey := filepath.Join(sshDir, "keys", "work") + nestedID := filepath.Join(sshDir, "work", "id_rsa") + nestedPub := filepath.Join(sshDir, "keys", "work.pub") + nestedConfig := filepath.Join(sshDir, "keys", "config") + nestedKnown := filepath.Join(sshDir, "keys", "known_hosts") + mustWriteFile(t, nestedKey, sshPrivateKeyFixture()) + mustWriteFile(t, nestedID, "") + mustWriteFile(t, nestedPub, "ssh-ed25519 AAAA nested\n") + mustWriteFile(t, nestedConfig, "Host *\n") + mustWriteFile(t, nestedKnown, "example.com ssh-ed25519 AAAA\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, nestedKey) { + t.Fatalf("~/.ssh/keys/work is readable; deny list = %v", denied) + } + if !denyCovered(denied, nestedID) { + t.Fatalf("~/.ssh/work/id_rsa is readable; deny list = %v", denied) + } + if denyCovered(denied, nestedPub) { + t.Fatalf("nested *.pub was denied; option 2 keeps public keys readable") + } + if denyCovered(denied, nestedConfig) { + t.Fatalf("nested config was denied") + } + if denyCovered(denied, nestedKnown) { + t.Fatalf("nested known_hosts was denied") + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestLinuxBwrapAndSeatbeltKeepLexicalCredentialSymlinkPaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + realDir := t.TempDir() + + gnupgLink := filepath.Join(home, ".gnupg") + gnupgTarget := filepath.Join(realDir, "gnupg-store") + if err := os.MkdirAll(gnupgTarget, 0o700); err != nil { + t.Fatal(err) + } + mustSymlink(t, gnupgTarget, gnupgLink) + + gitLink := filepath.Join(home, ".git-credentials") + gitTarget := filepath.Join(realDir, "git-credentials") + mustWriteFile(t, gitTarget, "x") + mustSymlink(t, gitTarget, gitLink) + + sshLink := filepath.Join(home, ".ssh", "id_ed25519") + sshTarget := filepath.Join(realDir, "id_ed25519") + mustWriteFile(t, sshTarget, "") + mustSymlink(t, sshTarget, sshLink) + + denied := sshGPGDenied(t, home, nil) + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: denied, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + sbpl := strings.Join(denyReadRules(profile.FileSystem), "\n") + for _, candidate := range []string{gnupgLink, gitLink, sshLink} { + lexical := normalizeProfilePathLexically(candidate) + if !strings.Contains(sbpl, sandboxProfileString(lexical)) { + t.Fatalf("Seatbelt rules missing lexical pathname %q:\n%s", lexical, sbpl) + } + } + newGit := filepath.Join(realDir, "other-credentials") + mustWriteFile(t, newGit, "retargeted") + if err := os.Remove(gitLink); err != nil { + t.Fatal(err) + } + mustSymlink(t, newGit, gitLink) + + assertLinuxCredentialPlanRejected(t, profile) + + deniedAfter := sshGPGDenied(t, home, nil) + lexicalGit := normalizeProfilePathLexically(gitLink) + if !denyListedExact(deniedAfter, lexicalGit) { + t.Fatalf("lexical git-credentials path missing after retarget: %v", deniedAfter) + } + newResolved := normalizeProfilePath(gitLink) + if newResolved != "" && newResolved != lexicalGit && !denyListedExact(deniedAfter, newResolved) { + t.Fatalf("retargeted git-credentials target %q missing from deny list %v", newResolved, deniedAfter) + } + if denyCovered(deniedAfter, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestSSHConfigDiscoveryBoundsOversizedConfig(t *testing.T) { + home := t.TempDir() + workKey := filepath.Join(home, "keys", "work_ed25519") + mustWriteFile(t, workKey, "") + padding := strings.Repeat("#", sshConfigMaxBytes+64*1024) + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile ~/keys/work_ed25519\n"+padding) + + start := time.Now() + denied := sshGPGDenied(t, home, nil) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("oversized config discovery took %s", elapsed) + } + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile in the first 1 MiB of an oversized config is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsFollowsSymlinkedSSHConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + workKey := filepath.Join(home, "keys", "work_ed25519") + mustWriteFile(t, workKey, "") + realConfig := filepath.Join(t.TempDir(), "root-config") + mustWriteFile(t, realConfig, "IdentityFile ~/keys/work_ed25519\n") + mustSymlink(t, realConfig, filepath.Join(home, ".ssh", "config")) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile via symlinked ~/.ssh/config is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } + if denyCovered(denied, filepath.Join(home, ".ssh", "config")) { + t.Fatalf("~/.ssh/config was denied") + } +} + +func TestCredentialDenyReadPathsFollowsSymlinkedSSHConfigInclude(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + includedKey := filepath.Join(home, "keys", "included_ed25519") + mustWriteFile(t, includedKey, "") + realInclude := filepath.Join(t.TempDir(), "extra_config") + mustWriteFile(t, realInclude, "IdentityFile ~/keys/included_ed25519\n") + mustWriteFile(t, filepath.Join(sshDir, "config"), "Include extra_config\n") + mustSymlink(t, realInclude, filepath.Join(sshDir, "extra_config")) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, includedKey) { + t.Fatalf("IdentityFile via symlinked Include target is readable; deny list = %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestUnreadableEnforcementPreservesLexicalWhenSSHDirIsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + realSSH := filepath.Join(t.TempDir(), "ssh-store") + idEd := filepath.Join(realSSH, "id_ed25519") + mustWriteFile(t, idEd, "") + mustSymlink(t, realSSH, filepath.Join(home, ".ssh")) + + lexicalKey := filepath.Join(home, ".ssh", "id_ed25519") + lexical := normalizeProfilePathLexically(lexicalKey) + if info, err := os.Lstat(lexical); err != nil { + t.Fatal(err) + } else if info.Mode().Type() == os.ModeSymlink { + t.Fatalf("expected regular leaf under a symlinked ~/.ssh, got symlink") + } + + denied := sshGPGDenied(t, home, nil) + if !denyListedExact(denied, lexical) { + t.Fatalf("lexical candidate %q missing from deny list %v", lexical, denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } + + enforced := unreadableEnforcementPaths(denied) + if !denyListedExact(enforced, lexical) { + t.Fatalf("lexical path %q missing from enforcement list %v", lexical, enforced) + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: denied, + }, + } + args := linuxBwrapFilesystemArgs(profile) + sbpl := strings.Join(denyReadRules(profile.FileSystem), "\n") + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", lexical) + if !strings.Contains(sbpl, sandboxProfileString(lexical)) { + t.Fatalf("Seatbelt rules missing lexical pathname %q:\n%s", lexical, sbpl) + } +} + +func TestSSHShouldDenyReferencedPathExemptsKnownHostsFamilyAndDevNull(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + keepReadable := []string{ + filepath.Join(sshDir, "known_hosts"), + filepath.Join(sshDir, "known_hosts2"), + filepath.Join(sshDir, "known_hosts.old"), + filepath.Join(sshDir, "ssh_known_hosts"), + filepath.Join(sshDir, "ssh_known_hosts2"), + "/dev/null", + os.DevNull, + } + for _, path := range keepReadable { + if sshShouldDenyReferencedPath(path, home, sshDir) { + t.Fatalf("%q must stay readable (known-hosts family or /dev/null)", path) + } + } + idEd := filepath.Join(sshDir, "id_ed25519") + if !sshShouldDenyReferencedPath(idEd, home, sshDir) { + t.Fatalf("well-known SSH key under fake home was not a deny candidate") + } + if !sshShouldDenyReferencedPath(filepath.Join(sshDir, "custom-key"), home, sshDir) { + t.Fatalf("non-exempt referenced path was not a deny candidate") + } + if !sshShouldDenyReferencedPath(filepath.Join(sshDir, "known_hosts.private"), home, sshDir) { + t.Fatalf("known_hosts.private must not be treated as a known-hosts family name") + } +} + +func TestCredentialDenyReadPathsKeepsKnownHostsFamilyFromConfig(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + known2 := filepath.Join(sshDir, "known_hosts2") + sshKnown := filepath.Join(home, "ssh_known_hosts") + mustWriteFile(t, known2, "example.com ssh-ed25519 AAAA\n") + mustWriteFile(t, sshKnown, "example.com ssh-ed25519 AAAA\n") + mustWriteFile(t, filepath.Join(sshDir, "config"), "UserKnownHostsFile ~/.ssh/known_hosts2 /dev/null\nGlobalKnownHostsFile "+sshKnown+"\n") + + denied := sshGPGDenied(t, home, nil) + if denyCovered(denied, known2) { + t.Fatalf("known_hosts2 was denied because UserKnownHostsFile pointed at it: %v", denied) + } + if denyCovered(denied, sshKnown) { + t.Fatalf("ssh_known_hosts was denied because GlobalKnownHostsFile pointed at it: %v", denied) + } + if denyCovered(denied, filepath.Join(sshDir, "config")) { + t.Fatalf("~/.ssh/config was denied") + } + if denyListedExact(denied, filepath.Clean("/dev/null")) || denyListedExact(denied, "/dev/null") { + t.Fatalf("/dev/null was denied from UserKnownHostsFile: %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestWalkSSHPrivateKeyFilesFindsKeyAfterCrowdedSiblingDir(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + junkDir := filepath.Join(sshDir, "aaa_known_hosts.d") + if err := os.MkdirAll(junkDir, 0o700); err != nil { + t.Fatal(err) + } + for i := 0; i < sshPrivateKeyWalkPageSize+32; i++ { + mustWriteFile(t, filepath.Join(junkDir, fmt.Sprintf("host-%04d", i)), "ssh-ed25519 AAAA\n") + } + nestedKey := filepath.Join(sshDir, "keys", "work_ed25519") + mustWriteFile(t, nestedKey, sshPrivateKeyFixture()) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, nestedKey) { + t.Fatalf("private key in a sibling of a crowded directory was not found; deny list = %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesPuttyPPK(t *testing.T) { + home := t.TempDir() + ppk := filepath.Join(home, ".ssh", "putty-key.ppk") + custom := filepath.Join(home, ".ssh", "custom-putty") + mustWriteFile(t, ppk, "") + mustWriteFile(t, custom, puttyPrivateKeyFixture()) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, ppk) { + t.Fatalf(".ppk is readable; deny list = %v", denied) + } + if !denyCovered(denied, custom) { + t.Fatalf("PuTTY-User-Key-File sniff missed custom-putty; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsPinsResolvedTargetWithoutOSSymlink(t *testing.T) { + home := t.TempDir() + lexicalKey := normalizeProfilePathLexically(filepath.Join(home, ".ssh", "id_ed25519")) + resolvedKey := filepath.Join(t.TempDir(), "resolved-id_ed25519-target") + testCredentialPathAlias = func(lexical string) (string, bool, bool) { + if lexical == lexicalKey { + return resolvedKey, true, true + } + return "", false, false + } + t.Cleanup(func() { testCredentialPathAlias = nil }) + + denied := sshGPGDenied(t, home, nil) + if !denyListedExact(denied, resolvedKey) { + t.Fatalf("resolved-target half missing; removing the sshKeys candidates append would cause this: %v", denied) + } + if !denyListedExact(denied, lexicalKey) { + t.Fatalf("lexical symlink extra missing: %v", denied) + } + + enforced := unreadableEnforcementPaths([]string{lexicalKey}) + if !denyListedExact(enforced, resolvedKey) { + t.Fatalf("enforcement list missing resolved target %q: %v", resolvedKey, enforced) + } + if !denyListedExact(enforced, lexicalKey) { + t.Fatalf("enforcement list missing lexical extra %q: %v", lexicalKey, enforced) + } +} + +func TestUnreadableEnforcementPathsSkipsNonSymlinkSpellingRewrite(t *testing.T) { + home := t.TempDir() + lexicalKey := normalizeProfilePathLexically(filepath.Join(home, ".ssh", "id_ed25519")) + shortName := filepath.Join(t.TempDir(), "RUNNER~1", "id_ed25519") + testCredentialPathAlias = func(lexical string) (string, bool, bool) { + if lexical == lexicalKey { + return shortName, false, true + } + return "", false, false + } + t.Cleanup(func() { testCredentialPathAlias = nil }) + + if pathResolutionInvolvesSymlink(lexicalKey) { + t.Fatalf("8.3-style rewrite must not count as a symlink") + } + enforced := unreadableEnforcementPaths([]string{lexicalKey}) + if denyListedExact(enforced, lexicalKey) { + t.Fatalf("non-symlink spelling rewrite dual-added lexical dest %q: %v", lexicalKey, enforced) + } + if !denyListedExact(enforced, shortName) { + t.Fatalf("canonical 8.3-style dest missing: %v", enforced) + } + if got := unreadableEnforcementPath(lexicalKey); got != shortName { + t.Fatalf("bwrap dest = %q, want canonical %q", got, shortName) + } + + denied := sshGPGDenied(t, home, nil) + if denyListedExact(denied, lexicalKey) && lexicalKey != shortName { + t.Fatalf("lexical 8.3 extra was dual-added: %v", denied) + } + if !denyListedExact(denied, shortName) { + t.Fatalf("canonical ssh key missing after 8.3-style rewrite: %v", denied) + } +} + +func TestCredentialDenyReadPathsDeniesKnownHostsPrivateNamedKey(t *testing.T) { + home := t.TempDir() + key := filepath.Join(home, ".ssh", "known_hosts.private") + mustWriteFile(t, key, sshPrivateKeyFixture()) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, key) { + t.Fatalf("known_hosts.private with a private-key payload is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } + if denyCovered(denied, filepath.Join(home, ".ssh", "known_hosts")) { + t.Fatalf("supported known_hosts was denied") + } +} + +func TestWalkSSHPrivateKeyFilesDeniesCustomNamedSymlinkToPrivateKey(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + target := filepath.Join(t.TempDir(), "real-key") + mustWriteFile(t, target, sshPrivateKeyFixture()) + link := filepath.Join(home, ".ssh", "work") + mustSymlink(t, target, link) + + denied := sshGPGDenied(t, home, nil) + lexical := normalizeProfilePathLexically(link) + if !denyListedExact(denied, lexical) && !denyListedExact(denied, link) { + t.Fatalf("custom-named symlink lost its lexical deny entry; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsNestedGPGAllowReadKeepsParentDirAndCarvesOut(t *testing.T) { + home := t.TempDir() + key := filepath.Join(home, ".gnupg", "private-keys-v1.d", "keygrip.key") + mustWriteFile(t, key, "fake-keygrip") + mustWriteFile(t, filepath.Join(home, ".gnupg", "secring.gpg"), "fake-secring") + mustWriteFile(t, filepath.Join(home, ".git-credentials"), "https://user:token@github.com") + + allow := []string{key} + denied := sshGPGDenied(t, home, allow) + gnupg := normalizeProfilePath(filepath.Join(home, ".gnupg")) + if !denyListedExact(denied, gnupg) { + t.Fatalf("nested allowRead must keep parent ~/.gnupg in DenyReadIfExists: %v", denied) + } + if !denyCovered(denied, filepath.Join(home, ".git-credentials")) { + t.Fatalf("git-credentials must stay denied when only a nested GPG key is allowed: %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestLinuxBwrapAndSeatbeltHonorNestedGPGAllowRead(t *testing.T) { + home := t.TempDir() + key := filepath.Join(home, ".gnupg", "private-keys-v1.d", "keygrip.key") + secring := filepath.Join(home, ".gnupg", "secring.gpg") + mustWriteFile(t, key, "fake-keygrip") + mustWriteFile(t, secring, "fake-secring") + + allow := []string{key} + creds := credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, allow) + gnupg := normalizeProfilePath(filepath.Join(home, ".gnupg")) + if !denyListedExact(creds.Paths, gnupg) { + t.Fatalf("nested allowRead must keep parent ~/.gnupg in DenyReadIfExists: %v", creds.Paths) + } + if !denyListedExact(creds.Carveouts, normalizeProfilePath(key)) { + t.Fatalf("nested allowRead key must be in DenyReadCarveouts: %v", creds.Carveouts) + } + if denyCovered(creds.Carveouts, secring) { + t.Fatalf("secring.gpg was unexpectedly carved out: %v", creds.Carveouts) + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator), normalizeProfilePath(key)}, + DenyReadIfExists: creds.Paths, + DenyReadCarveouts: creds.Carveouts, + }, + } + args := linuxBwrapFilesystemArgs(profile) + if !argsContainSequence(args, "--perms", "111", "--tmpfs", gnupg) { + t.Fatalf("bwrap should tmpfs-mask ~/.gnupg to protect sibling secrets: %#v", args) + } + normKey := normalizeProfilePath(key) + if !argsContainSequence(args, "--ro-bind", normKey, normKey) { + t.Fatalf("bwrap should --ro-bind the carved-out key: %#v", args) + } + normSecring := normalizeProfilePath(secring) + if argsContainSequence(args, "--ro-bind", normSecring, normSecring) { + t.Fatalf("bwrap unexpectedly rebound secring: %#v", args) + } + + full := seatbeltProfileFromPermissionProfile(profile, Policy{}, "") + denyIdx := strings.LastIndex(full, `(deny file-read* (subpath "`+sandboxProfileString(gnupg)+`"))`) + if denyIdx < 0 { + t.Fatalf("full Seatbelt profile must deny ~/.gnupg subtree to protect sibling secrets:\n%s", full) + } + keyLit := sandboxProfileString(normalizeProfilePath(key)) + allowIdx := strings.LastIndex(full, `(allow file-read* file-test-existence (literal "`+keyLit+`"))`) + if allowIdx < 0 || allowIdx < denyIdx { + t.Fatalf("Seatbelt profile must allow the carved-out key AFTER the parent deny rule:\n%s", full) + } + if strings.Contains(full, `(allow file-read* file-test-existence (literal "`+sandboxProfileString(secring)+`"))`) { + t.Fatalf("Seatbelt profile must not allow sibling secring.gpg:\n%s", full) + } +} + +func TestExpandSSHConfigPathHomeEnvFromSuppliedHome(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + got := expandSSHConfigPath("${HOME}/keys/work_ed25519", home, sshDir) + want := filepath.Join(home, "keys", "work_ed25519") + if got != want { + t.Fatalf("expandSSHConfigPath(${HOME}) = %q, want %q", got, want) + } + got = expandSSHConfigPath("$HOME/keys/work_ed25519", home, sshDir) + if got != want { + t.Fatalf("expandSSHConfigPath($HOME) = %q, want %q", got, want) + } + if expandSSHConfigPath("${NOTHOME}/keys/x", home, sshDir) != "" { + t.Fatalf("unknown ${NOTHOME} must be dropped, not joined under ~/.ssh") + } + if expandSSHConfigPath("$NOTHOME/keys/x", home, sshDir) != "" { + t.Fatalf("unknown $NOTHOME must be dropped, not joined under ~/.ssh") + } + nonsense := filepath.Join(sshDir, "${NOTHOME}", "keys", "x") + if expandSSHConfigPath("${NOTHOME}/keys/x", home, sshDir) == nonsense { + t.Fatalf("unknown ${NOTHOME} was joined under ~/.ssh as %q", nonsense) + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFileHomeEnv(t *testing.T) { + home := t.TempDir() + workKey := filepath.Join(home, "keys", "work_ed25519") + mustWriteFile(t, workKey, "") + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile ${HOME}/keys/work_ed25519\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile ${HOME}/keys/work_ed25519 is readable; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } + + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile $HOME/keys/work_ed25519\nIdentityFile ${NOTHOME}/keys/x\n") + denied = sshGPGDenied(t, home, nil) + if !denyCovered(denied, workKey) { + t.Fatalf("IdentityFile $HOME/keys/work_ed25519 is readable; deny list = %v", denied) + } + nonsense := filepath.Join(home, ".ssh", "${NOTHOME}", "keys", "x") + if denyListedExact(denied, nonsense) || denyCovered(denied, nonsense) { + t.Fatalf("unknown ${NOTHOME} was joined under ~/.ssh: %v", denied) + } +} + +func TestWalkSSHPrivateKeyFilesDeniesPrivateKeyPayloadNamedPub(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + fakePub := filepath.Join(sshDir, "work.pub") + realPub := filepath.Join(sshDir, "id_ed25519.pub") + mustWriteFile(t, fakePub, sshPrivateKeyFixture()) + mustWriteFile(t, realPub, "ssh-ed25519 AAAA public\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, fakePub) { + t.Fatalf("private-key payload at ~/.ssh/work.pub is readable; deny list = %v", denied) + } + if denyCovered(denied, realPub) { + t.Fatalf("real ssh-ed25519 .pub was denied; public keys must stay readable") + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFilePubWithPrivateKeyPayload(t *testing.T) { + home := t.TempDir() + fakePub := filepath.Join(home, "keys", "work.pub") + realPub := filepath.Join(home, "keys", "id_ed25519.pub") + mustWriteFile(t, fakePub, sshPrivateKeyFixture()) + mustWriteFile(t, realPub, "ssh-ed25519 AAAA public\n") + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile ~/keys/work.pub\nIdentityFile ~/keys/id_ed25519.pub\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, fakePub) { + t.Fatalf("IdentityFile ~/keys/work.pub with private-key payload is readable; deny list = %v", denied) + } + if denyCovered(denied, realPub) { + t.Fatalf("real ssh-ed25519 .pub at IdentityFile path was denied; public keys must stay readable") + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFileNamedKnownHosts(t *testing.T) { + home := t.TempDir() + fakeKnown := filepath.Join(home, "keys", "known_hosts") + realKnown := filepath.Join(home, "other", "known_hosts") + mustWriteFile(t, fakeKnown, sshPrivateKeyFixture()) + mustWriteFile(t, realKnown, "example.com ssh-ed25519 AAAA\n") + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile ~/keys/known_hosts\nUserKnownHostsFile ~/other/known_hosts\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, fakeKnown) { + t.Fatalf("IdentityFile of a private-key file named known_hosts is readable; deny list = %v", denied) + } + if denyCovered(denied, realKnown) { + t.Fatalf("real known_hosts file was denied") + } + if denyCovered(denied, filepath.Join(home, ".ssh")) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestLinuxBwrapAndSeatbeltHonorNestedGPGAllowReadThroughDirSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + gnupgTarget := filepath.Join(t.TempDir(), "gnupg-store") + key := filepath.Join(gnupgTarget, "private-keys-v1.d", "keygrip.key") + secring := filepath.Join(gnupgTarget, "secring.gpg") + mustWriteFile(t, key, "fake-keygrip") + mustWriteFile(t, secring, "fake-secring") + mustSymlink(t, gnupgTarget, filepath.Join(home, ".gnupg")) + + allow := []string{key} + creds := credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, allow) + canonicalGnupg := normalizeProfilePath(filepath.Join(home, ".gnupg")) + if canonicalGnupg == "" || !denyListedExact(creds.Paths, canonicalGnupg) { + t.Fatalf("canonical ~/.gnupg dir deny must be retained: %v", creds.Paths) + } + if !denyListedExact(creds.Carveouts, normalizeProfilePath(key)) { + t.Fatalf("nested allowRead key must be in DenyReadCarveouts: %v", creds.Carveouts) + } + if denyCovered(creds.Carveouts, secring) { + t.Fatalf("secring.gpg must not be carved out: %v", creds.Carveouts) + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator), normalizeProfilePath(key)}, + DenyReadIfExists: creds.Paths, + DenyReadCarveouts: creds.Carveouts, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + + full := seatbeltProfileFromPermissionProfile(profile, Policy{}, "") + denyIdx := strings.LastIndex(full, `(deny file-read* (subpath "`+sandboxProfileString(canonicalGnupg)+`"))`) + if denyIdx < 0 { + t.Fatalf("full Seatbelt profile must deny canonical ~/.gnupg subtree:\n%s", full) + } + keyLit := sandboxProfileString(normalizeProfilePath(key)) + allowIdx := strings.LastIndex(full, `(allow file-read* file-test-existence (literal "`+keyLit+`"))`) + if allowIdx < 0 || allowIdx < denyIdx { + t.Fatalf("Seatbelt profile must allow the carved-out key AFTER the parent deny rule:\n%s", full) + } + if strings.Contains(full, `(allow file-read* file-test-existence (literal "`+sandboxProfileString(secring)+`"))`) { + t.Fatalf("Seatbelt profile must not allow sibling secring.gpg:\n%s", full) + } +} + +func TestLinuxBwrapAndSeatbeltHonorNestedGPGDirAllowReadThroughDirSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + gnupgTarget := filepath.Join(t.TempDir(), "gnupg-store") + keyDir := filepath.Join(gnupgTarget, "private-keys-v1.d") + key := filepath.Join(keyDir, "keygrip.key") + mustWriteFile(t, key, "fake-keygrip") + mustWriteFile(t, filepath.Join(gnupgTarget, "secring.gpg"), "fake-secring") + mustSymlink(t, gnupgTarget, filepath.Join(home, ".gnupg")) + + allow := []string{filepath.Join(home, ".gnupg", "private-keys-v1.d")} + creds := credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, allow) + canonicalGnupg := normalizeProfilePath(filepath.Join(home, ".gnupg")) + canonicalKeyDir := normalizeCredentialCarveoutPath(filepath.Join(home, ".gnupg", "private-keys-v1.d")) + if canonicalKeyDir == "" { + t.Fatalf("nested directory grant did not produce a credential carveout") + } + if canonicalGnupg == "" || !denyListedExact(creds.Paths, canonicalGnupg) { + t.Fatalf("canonical ~/.gnupg must stay denied so the directory carveout can re-bind: %v", creds.Paths) + } + if !denyListedExact(creds.Carveouts, canonicalKeyDir) { + t.Fatalf("canonical private-keys-v1.d carveout missing: %v", creds.Carveouts) + } + if denyListedExact(creds.Paths, canonicalKeyDir) || denyListedExact(creds.Paths, keyDir) { + t.Fatalf("nested directory grant itself was emitted as a deny path: %v", creds.Paths) + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator), canonicalKeyDir}, + DenyReadIfExists: creds.Paths, + DenyReadCarveouts: creds.Carveouts, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + + full := seatbeltProfileFromPermissionProfile(profile, Policy{}, "") + if !strings.Contains(full, `(allow file-read* file-test-existence (subpath "`+sandboxProfileString(canonicalKeyDir)+`"))`) { + t.Fatalf("full Seatbelt profile missing canonical directory carveout:\n%s", full) + } +} + +func TestLinuxBwrapMasksLiveAndDanglingCredentialSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + realDir := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + config := filepath.Join(sshDir, "config") + knownHosts := filepath.Join(sshDir, "known_hosts") + pub := filepath.Join(sshDir, "id_ed25519.pub") + mustWriteFile(t, config, "Host *\n") + mustWriteFile(t, knownHosts, "github.com ssh-ed25519 AAAA\n") + mustWriteFile(t, pub, "ssh-ed25519 AAAA public\n") + + liveTarget := filepath.Join(realDir, "id_ed25519") + mustWriteFile(t, liveTarget, sshPrivateKeyFixture()) + liveLink := filepath.Join(sshDir, "id_ed25519") + mustSymlink(t, liveTarget, liveLink) + + danglingTarget := filepath.Join(realDir, "missing-id_rsa") + danglingLink := filepath.Join(sshDir, "id_rsa") + mustSymlink(t, danglingTarget, danglingLink) + + gitTarget := filepath.Join(realDir, "git-credentials") + mustWriteFile(t, gitTarget, "x") + gitLink := filepath.Join(home, ".git-credentials") + mustSymlink(t, gitTarget, gitLink) + + denied := sshGPGDenied(t, home, nil) + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: denied, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestLinuxBwrapSkipsFileBindsUnderOverlaidCredentialParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + realDir := t.TempDir() + workTarget := filepath.Join(realDir, "work") + mustWriteFile(t, workTarget, sshPrivateKeyFixture()) + workLink := filepath.Join(sshDir, "work") + mustSymlink(t, workTarget, workLink) + idEd := filepath.Join(sshDir, "id_ed25519") + mustWriteFile(t, idEd, sshPrivateKeyFixture()) + config := filepath.Join(sshDir, "config") + mustWriteFile(t, config, "Host *\n") + danglingSibling := filepath.Join(sshDir, "config.local") + mustSymlink(t, filepath.Join(realDir, "missing-config.local"), danglingSibling) + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workLink) { + t.Fatalf("denied symlink ~/.ssh/work missing from deny list: %v", denied) + } + if !denyCovered(denied, idEd) { + t.Fatalf("denied regular ~/.ssh/id_ed25519 missing from deny list: %v", denied) + } + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: denied, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestLinuxBwrapBindsDeniedFileWhenParentOverlayFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is not reliably available on Windows CI") + } + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + realDir := t.TempDir() + workTarget := filepath.Join(realDir, "work") + mustWriteFile(t, workTarget, sshPrivateKeyFixture()) + workLink := filepath.Join(sshDir, "work") + mustSymlink(t, workTarget, workLink) + idEd := filepath.Join(sshDir, "id_ed25519") + mustWriteFile(t, idEd, sshPrivateKeyFixture()) + config := filepath.Join(sshDir, "config") + mustWriteFile(t, config, "Host *\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, workLink) { + t.Fatalf("denied symlink ~/.ssh/work missing from deny list: %v", denied) + } + if !denyCovered(denied, idEd) { + t.Fatalf("denied regular ~/.ssh/id_ed25519 missing from deny list: %v", denied) + } + + // Execute-only: Lstat of children still classifies the symlink+file, but + // ReadDir fails so the tmpfs overlay is not applied. Build the deny list + // first while the directory is readable. + if err := os.Chmod(sshDir, 0o111); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(sshDir, 0o700) }) + if _, err := os.ReadDir(sshDir); err == nil { + t.Skip("parent ReadDir succeeded after chmod 0111 (likely running as root)") + } + + profile := PermissionProfile{ + FileSystem: FileSystemPolicy{ + Kind: FileSystemRestricted, + ReadRoots: []string{string(filepath.Separator)}, + DenyReadIfExists: denied, + }, + } + assertLinuxCredentialPlanRejected(t, profile) + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesSSHConfigIdentityFileNamedConfigOrAuthorizedKeys(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + fakeConfig := filepath.Join(home, "keys", "config") + fakeAuthorized := filepath.Join(home, "keys", "authorized_keys") + realConfig := filepath.Join(sshDir, "config") + realAuthorized := filepath.Join(sshDir, "authorized_keys") + mustWriteFile(t, fakeConfig, sshPrivateKeyFixture()) + mustWriteFile(t, fakeAuthorized, sshPrivateKeyFixture()) + mustWriteFile(t, realAuthorized, "ssh-ed25519 AAAA user@host\n") + mustWriteFile(t, realConfig, "IdentityFile ~/keys/config\nIdentityFile ~/keys/authorized_keys\nUserKnownHostsFile /dev/null\n") + + denied := sshGPGDenied(t, home, nil) + if !denyCovered(denied, fakeConfig) { + t.Fatalf("IdentityFile ~/keys/config with private-key payload is readable; deny list = %v", denied) + } + if !denyCovered(denied, fakeAuthorized) { + t.Fatalf("IdentityFile ~/keys/authorized_keys with private-key payload is readable; deny list = %v", denied) + } + if denyCovered(denied, realConfig) { + t.Fatalf("real ~/.ssh/config was denied") + } + if denyCovered(denied, realAuthorized) { + t.Fatalf("real ~/.ssh/authorized_keys was denied") + } + if denyListedExact(denied, filepath.Clean("/dev/null")) || denyListedExact(denied, "/dev/null") { + t.Fatalf("/dev/null was denied from UserKnownHostsFile: %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestCredentialDenyReadPathsDeniesGNUPGHOME(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read is not applied on Windows") + } + home := t.TempDir() + alt := t.TempDir() + secring := filepath.Join(alt, "secring.gpg") + key := filepath.Join(alt, "private-keys-v1.d", "keygrip.key") + mustWriteFile(t, secring, "fake-secring") + mustWriteFile(t, key, "fake-keygrip") + env := []string{"HOME=" + home, "GNUPGHOME=" + alt} + + t.Run("inherited environment", func(t *testing.T) { + options := credentialPathOptionsFromEnvironment([]string{home}, env) + denied := credentialDenyReadPathsIn(options, nil).Paths + if !denyCovered(denied, alt) { + t.Fatalf("inherited GNUPGHOME is readable; deny list = %v", denied) + } + if !denyCovered(denied, secring) { + t.Fatalf("GNUPGHOME secring is readable; deny list = %v", denied) + } + if !denyCovered(denied, key) { + t.Fatalf("GNUPGHOME private-keys-v1.d is readable; deny list = %v", denied) + } + }) + + t.Run("command-supplied environment", func(t *testing.T) { + creds := credentialDenyReadPaths(Policy{}, "", env, nil) + if !denyCovered(creds.Paths, alt) { + t.Fatalf("command-supplied GNUPGHOME is readable; deny list = %v", creds.Paths) + } + if !denyCovered(creds.Paths, key) { + t.Fatalf("command-supplied GNUPGHOME subtree is readable; deny list = %v", creds.Paths) + } + }) + + t.Run("allowRead reincludes", func(t *testing.T) { + options := credentialPathOptionsFromEnvironment([]string{home}, env) + denied := credentialDenyReadPathsIn(options, []string{alt}).Paths + if denyCovered(denied, alt) { + t.Fatalf("allowRead GNUPGHOME is still denied: %v", denied) + } + if denyCovered(denied, key) { + t.Fatalf("allowRead GNUPGHOME subtree is still denied: %v", denied) + } + }) +} + +func TestCredentialDenyReadPathsTraversesNestedDirectorySymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read is not applied on Windows") + } + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + keyStore := t.TempDir() + workKey := filepath.Join(keyStore, "work") + mustWriteFile(t, workKey, sshPrivateKeyFixture()) + + // Symlink ~/.ssh/keys -> keyStore + mustSymlink(t, keyStore, filepath.Join(sshDir, "keys")) + mustWriteFile(t, filepath.Join(sshDir, "config"), "Host *\n") + + denied := sshGPGDenied(t, home, nil) + lexicalTarget := filepath.Join(sshDir, "keys", "work") + if !denyCovered(denied, lexicalTarget) && !denyCovered(denied, workKey) { + t.Fatalf("key reachable through directory symlink was not denied; deny list = %v", denied) + } + if denyCovered(denied, filepath.Join(sshDir, "config")) { + t.Fatalf("~/.ssh/config was unexpectedly denied: %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale: %v", denied) + } +} + +func TestSSHDiscoveryUsesCommandEnvironment(t *testing.T) { + home := t.TempDir() + keys := t.TempDir() + t.Setenv("SSH_KEY_DIR", "") + t.Setenv("SSH_CONFIG_DIR", "") + for _, variable := range []string{"${SSH_KEY_DIR}", "$SSH_KEY_DIR"} { + t.Run(variable, func(t *testing.T) { + key := filepath.Join(keys, "work") + mustWriteFile(t, key, sshPrivateKeyFixture()) + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "IdentityFile "+variable+"/work\n") + options := credentialPathOptionsFromEnvironment([]string{home}, []string{ + "HOME=" + home, "SSH_KEY_DIR=" + keys, + }) + got := credentialDenyReadPathsIn(options, nil) + if len(got.DiscoveryErrors) != 0 { + t.Fatalf("discovery failed: %v", got.DiscoveryErrors) + } + if !denyCovered(got.Paths, key) { + t.Fatal("command environment key missing from credential denies") + } + }) + } + t.Run("included configuration", func(t *testing.T) { + configDir := t.TempDir() + key := filepath.Join(keys, "included") + mustWriteFile(t, key, sshPrivateKeyFixture()) + mustWriteFile(t, filepath.Join(home, ".ssh", "config"), "Include ${SSH_CONFIG_DIR}/extra\n") + mustWriteFile(t, filepath.Join(configDir, "extra"), "IdentityFile ${SSH_KEY_DIR}/included\n") + options := credentialPathOptionsFromEnvironment([]string{home}, []string{ + "HOME=" + home, "SSH_KEY_DIR=" + keys, "SSH_CONFIG_DIR=" + configDir, + }) + got := credentialDenyReadPathsIn(options, nil) + if len(got.DiscoveryErrors) != 0 || !denyCovered(got.Paths, key) { + t.Fatalf("command environment did not reach included configuration: %v", got.DiscoveryErrors) + } + }) +} + +func TestSSHConfigEnvironmentPrecedence(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + t.Setenv("SSH_KEY_DIR", filepath.Join(home, "inherited")) + for _, tc := range []struct { + name string + input string + env []string + want string + }{ + {"inherited", "${SSH_KEY_DIR}/work", nil, filepath.Join(home, "inherited", "work")}, + {"override", "${SSH_KEY_DIR}/work", []string{"SSH_KEY_DIR=" + filepath.Join(home, "command")}, filepath.Join(home, "command", "work")}, + {"last override wins", "${SSH_KEY_DIR}/work", []string{"SSH_KEY_DIR=ignored", "SSH_KEY_DIR=" + filepath.Join(home, "last")}, filepath.Join(home, "last", "work")}, + {"empty override expands", "~/keys/${KEY_SUFFIX}", []string{"KEY_SUFFIX="}, filepath.Join(home, "keys")}, + {"unset variable drops path", "${UNSET_VAR}/work", nil, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := expandSSHConfigPath(tc.input, home, sshDir, tc.env...); got != tc.want { + t.Fatalf("expanded path = %q, want %q", got, tc.want) + } + }) + } + if got := expandSSHConfigPath("$HOME/work", home, sshDir, "HOME=ignored"); got != filepath.Join(home, "work") { + t.Fatalf("HOME must use discovery home, got %q", got) + } +} + +func TestOpenSSHPathParsingEscapesAndEnv(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + t.Setenv("SSH_KEY_DIR", filepath.Join(home, "secret-keys")) + + t.Run("unquoted escaped spaces", func(t *testing.T) { + tokens := splitSSHTokens(`IdentityFile ~/My\ Keys/work`) + if len(tokens) != 2 || tokens[0] != "IdentityFile" || tokens[1] != "~/My Keys/work" { + t.Fatalf("splitSSHTokens unexpected tokens: %#v", tokens) + } + }) + + t.Run("environment variable expansion", func(t *testing.T) { + got := expandSSHConfigPath("${SSH_KEY_DIR}/work", home, sshDir) + want := filepath.Join(home, "secret-keys", "work") + if got != want { + t.Fatalf("expandSSHConfigPath(${SSH_KEY_DIR}) = %q, want %q", got, want) + } + }) + + t.Run("unresolvable variable dropped", func(t *testing.T) { + got := expandSSHConfigPath("${DEFINITELY_UNSET_VAR_XYZ}/work", home, sshDir) + if got != "" { + t.Fatalf("expected unset variable to be dropped, got %q", got) + } + }) +} + +func TestAllowReadSingleFileInsideGNUPGPreservesSiblingDenies(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("credential deny-read is not applied on Windows") + } + home := t.TempDir() + gnupgDir := filepath.Join(home, ".gnupg") + publicFile := filepath.Join(gnupgDir, "public.txt") + secringFile := filepath.Join(gnupgDir, "secring.gpg") + keyFile := filepath.Join(gnupgDir, "private-keys-v1.d", "keygrip.key") + + mustWriteFile(t, publicFile, "public info") + mustWriteFile(t, secringFile, "secret keyring") + mustWriteFile(t, keyFile, "secret keygrip") + + options := credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + } + creds := credentialDenyReadPathsIn(options, []string{publicFile}) + + // 1. .gnupg must remain denied as a directory root + if !denyCovered(creds.Paths, gnupgDir) { + t.Fatalf("expected .gnupg directory to remain in deny list, got %v", creds.Paths) + } + + // 2. publicFile must be present in Carveouts + normPublic := normalizeProfilePath(publicFile) + if !denyListedExact(creds.Carveouts, normPublic) && !denyCovered(creds.Carveouts, normPublic) { + t.Fatalf("expected publicFile in Carveouts, got %v", creds.Carveouts) + } + + // 3. Sibling secrets must NOT be in Carveouts + if denyCovered(creds.Carveouts, secringFile) || denyCovered(creds.Carveouts, keyFile) { + t.Fatalf("sibling secrets unexpectedly carved out: %v", creds.Carveouts) + } + + // 4. In Seatbelt profile: verify public.txt has allow rule, while secring stays denied + fs := FileSystemPolicy{ + DenyReadIfExists: creds.Paths, + DenyReadCarveouts: creds.Carveouts, + } + sbRules := strings.Join(denyReadCarveoutRules(fs), "\n") + if !strings.Contains(sbRules, normPublic) && !strings.Contains(sbRules, publicFile) { + t.Fatalf("seatbelt rules missing allow for public file: %s", sbRules) + } + if strings.Contains(sbRules, secringFile) { + t.Fatalf("seatbelt rules allow sibling secret: %s", sbRules) + } +} + +func TestSSHSupportDirectivesCustomKnownHostsAndSocketPreserved(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + customHosts := filepath.Join(sshDir, "known_hosts_work") + agentSock := filepath.Join(home, "agent.sock") + mustWriteFile(t, customHosts, "example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...\n") + mustWriteFile(t, agentSock, "not-a-private-key") + config := filepath.Join(sshDir, "config") + mustWriteFile(t, config, "UserKnownHostsFile ~/.ssh/known_hosts_work\nIdentityAgent ~/agent.sock\n") + + denied := sshGPGDenied(t, home, nil) + if denyCovered(denied, customHosts) { + t.Fatalf("custom UserKnownHostsFile %q was denied: %v", customHosts, denied) + } + if denyCovered(denied, agentSock) { + t.Fatalf("IdentityAgent socket %q was denied: %v", agentSock, denied) + } +} + +func TestUnexpressibleNestedAllowReadPreservesParentCredentialDeny(t *testing.T) { + home := t.TempDir() + gnupgDir := filepath.Join(home, ".gnupg") + mustWriteFile(t, filepath.Join(gnupgDir, "secring.gpg"), "secret") + // An unexpressible nested path: for example, a nonexistent path whose parent fails or symlink + unexpressible := filepath.Join(gnupgDir, "nonexistent\x00path") + + options := credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + } + creds := credentialDenyReadPathsIn(options, []string{unexpressible}) + gnupgNorm := normalizeProfilePath(gnupgDir) + if !denyListedExact(creds.Paths, gnupgNorm) { + t.Fatalf("unexpressible nested allowRead must preserve parent credential dir deny %q: got %v", gnupgNorm, creds.Paths) + } +} + +func TestSSHDiscovery_DirectoryBindingAndReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows open directory handle holds a share lock preventing rename") + } + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + externalDir := t.TempDir() + externalKey := filepath.Join(externalDir, "external_id_ed25519") + mustWriteFile(t, externalKey, "-----BEGIN OPENSSH PRIVATE KEY-----\ndummy\n-----END OPENSSH PRIVATE KEY-----\n") + + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + symlinkPath := filepath.Join(sshDir, "my_custom_key") + if err := os.Symlink(externalKey, symlinkPath); err != nil { + t.Skipf("symlinks unsupported in this environment: %v", err) + } + + var replacedDir string + testSSHWalkChildHook = func(dir string) { + if replacedDir != "" { + return + } + replacedDir = dir + "_aside" + if err := os.Rename(dir, replacedDir); err != nil { + t.Fatalf("rename aside: %v", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir replacement: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "my_custom_key"), []byte("benign file"), 0o600); err != nil { + t.Fatalf("write benign file: %v", err) + } + } + defer func() { + testSSHWalkChildHook = nil + if replacedDir != "" { + _ = os.RemoveAll(sshDir) + _ = os.Rename(replacedDir, sshDir) + } + }() + + scanner := &sshDiscovery{} + candidates := scanner.privateKeyDenyCandidates(home) + + if len(scanner.errors) == 0 && !denyCovered(candidates, externalKey) { + t.Fatalf("directory replacement was silently ignored without protecting external key: candidates=%v, errors=%v", candidates, scanner.errors) + } + + testSSHWalkChildHook = nil + _ = os.RemoveAll(sshDir) + if err := os.Rename(replacedDir, sshDir); err != nil { + t.Fatalf("restore dir: %v", err) + } + replacedDir = "" + + controlScanner := &sshDiscovery{} + controlCandidates := controlScanner.privateKeyDenyCandidates(home) + if len(controlScanner.errors) != 0 { + t.Fatalf("unchanged directory control returned errors: %v", controlScanner.errors) + } + if !denyCovered(controlCandidates, externalKey) { + t.Fatalf("unchanged directory control must discover external key %q: got %v", externalKey, controlCandidates) + } +} + +func TestSSHConfig_RelativeIdentityFileWorkingDir(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + projectDir := t.TempDir() + + projectKey := filepath.Join(projectDir, "keys", "work") + mustWriteFile(t, projectKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nproject\n-----END OPENSSH PRIVATE KEY-----\n") + + decoyKey := filepath.Join(sshDir, "keys", "work") + mustWriteFile(t, decoyKey, "decoy benign file") + + externalKey := filepath.Join(t.TempDir(), "included_key") + mustWriteFile(t, externalKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nincluded\n-----END OPENSSH PRIVATE KEY-----\n") + + configPath := filepath.Join(sshDir, "config") + includeDir := filepath.Join(sshDir, "configs") + mustWriteFile(t, filepath.Join(includeDir, "sub.conf"), "IdentityFile "+filepath.ToSlash(externalKey)+"\n") + mustWriteFile(t, configPath, "IdentityFile keys/work\nInclude configs/*.conf\n") + + scanner := &sshDiscovery{workingDir: projectDir} + candidates := scanner.privateKeyDenyCandidates(home) + + normProjectKey := normalizeProfilePath(projectKey) + normDecoyKey := normalizeProfilePath(decoyKey) + normExternalKey := normalizeProfilePath(externalKey) + + if !denyCovered(candidates, normProjectKey) { + t.Fatalf("expected IdentityFile keys/work to resolve relative to working directory %q: candidates=%v", normProjectKey, candidates) + } + if denyCovered(candidates, normDecoyKey) { + t.Fatalf("decoy key under ~/.ssh must not be discovered: %v", candidates) + } + if !denyCovered(candidates, normExternalKey) { + t.Fatalf("expected Include relative pattern to resolve from ~/.ssh and discover %q: candidates=%v", normExternalKey, candidates) + } + + options := credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + BaseDirs: []string{projectDir}, + } + creds := credentialDenyReadPathsIn(options, []string{projectDir}) + if denyCovered(creds.Paths, normProjectKey) { + t.Fatalf("workspace key under allowed projectDir must be filtered out by AllowRead: %v", creds.Paths) + } + if !denyCovered(creds.Paths, normExternalKey) { + t.Fatalf("external key must remain denied despite workspace allowRead: %v", creds.Paths) + } +} + +func TestSSHConfig_EqualsSeparatorWhitespace(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + externalDir := t.TempDir() + + keyWithEquals := filepath.Join(externalDir, "key=work") + mustWriteFile(t, keyWithEquals, "-----BEGIN OPENSSH PRIVATE KEY-----\nequals\n-----END OPENSSH PRIVATE KEY-----\n") + + subConfWithEquals := filepath.Join(externalDir, "sub=conf.conf") + mustWriteFile(t, subConfWithEquals, "IdentityFile "+filepath.ToSlash(keyWithEquals)+"\n") + + cases := []struct { + directive string + wantKey string + wantVal []string + }{ + {"IdentityFile /path/to/key", "identityfile", []string{"/path/to/key"}}, + {"IdentityFile=/path/to/key", "identityfile", []string{"/path/to/key"}}, + {"IdentityFile =/path/to/key", "identityfile", []string{"/path/to/key"}}, + {"IdentityFile= /path/to/key", "identityfile", []string{"/path/to/key"}}, + {"IdentityFile = /path/to/key", "identityfile", []string{"/path/to/key"}}, + {"IdentityFile =/path/to/key=work", "identityfile", []string{"/path/to/key=work"}}, + {"Include /path/to/conf", "include", []string{"/path/to/conf"}}, + {"Include=/path/to/conf", "include", []string{"/path/to/conf"}}, + {"Include =/path/to/conf", "include", []string{"/path/to/conf"}}, + {"Include= /path/to/conf", "include", []string{"/path/to/conf"}}, + {"Include = /path/to/conf", "include", []string{"/path/to/conf"}}, + {"Include =/path/to/sub=conf", "include", []string{"/path/to/sub=conf"}}, + } + for _, tc := range cases { + key, vals := parseSSHDirective(tc.directive) + if key != tc.wantKey || len(vals) != len(tc.wantVal) || vals[0] != tc.wantVal[0] { + t.Fatalf("parseSSHDirective(%q) = (%q, %v), want (%q, %v)", tc.directive, key, vals, tc.wantKey, tc.wantVal) + } + } + + configPath := filepath.Join(sshDir, "config") + mustWriteFile(t, configPath, fmt.Sprintf("Include =%s\nIdentityFile =%s\n", filepath.ToSlash(subConfWithEquals), filepath.ToSlash(keyWithEquals))) + + scanner := &sshDiscovery{} + candidates := scanner.privateKeyDenyCandidates(home) + if !denyCovered(candidates, normalizeProfilePath(keyWithEquals)) { + t.Fatalf("expected discovery with '=' separators to find %q: candidates=%v", keyWithEquals, candidates) + } +} + +func TestSSHConfig_PreserveEmbeddedHashInFilename(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + externalDir := t.TempDir() + + keyWithHash := filepath.Join(externalDir, "key#work") + mustWriteFile(t, keyWithHash, "-----BEGIN OPENSSH PRIVATE KEY-----\nhash\n-----END OPENSSH PRIVATE KEY-----\n") + + confWithHash := filepath.Join(externalDir, "config#work") + mustWriteFile(t, confWithHash, "IdentityFile "+filepath.ToSlash(keyWithHash)+" # trailing comment\n") + + truncatedConf := filepath.Join(externalDir, "config") + _ = os.Remove(truncatedConf) + truncatedKey := filepath.Join(externalDir, "key") + _ = os.Remove(truncatedKey) + + configPath := filepath.Join(sshDir, "config") + mustWriteFile(t, configPath, fmt.Sprintf("# Full line comment\nInclude %s\n", filepath.ToSlash(confWithHash))) + + scanner := &sshDiscovery{} + candidates := scanner.privateKeyDenyCandidates(home) + if len(scanner.errors) != 0 { + t.Fatalf("unexpected discovery errors: %v", scanner.errors) + } + if !denyCovered(candidates, normalizeProfilePath(keyWithHash)) { + t.Fatalf("expected discovery to preserve embedded hash and find %q: candidates=%v", keyWithHash, candidates) + } +} + +func TestSSHDiscovery_EnvironmentLookupEmptyVsUnset(t *testing.T) { + home, sshDir := sshGPGNormalizationHome() + externalKey := filepath.Join(home, "external", "key") + t.Setenv("SSH_INHERITED_VAR", filepath.Join(home, "inherited")) + t.Setenv("SSH_INHERITED_EMPTY", "") + + if got := expandSSHConfigPath("${SSH_INHERITED_VAR}/key", home, sshDir); got != filepath.Join(home, "inherited", "key") { + t.Fatalf("nonempty inherited value = %q, want %q", got, filepath.Join(home, "inherited", "key")) + } + if got := expandSSHConfigPath("${SSH_INHERITED_EMPTY}"+externalKey, home, sshDir); got != externalKey { + t.Fatalf("inherited empty value = %q, want %q", got, externalKey) + } + if got := expandSSHConfigPath("${SSH_CMD_VAR}/key", home, sshDir, "SSH_CMD_VAR="+filepath.Join(home, "cmd")); got != filepath.Join(home, "cmd", "key") { + t.Fatalf("command-only value = %q, want %q", got, filepath.Join(home, "cmd", "key")) + } + if got := expandSSHConfigPath("${SSH_INHERITED_VAR}"+externalKey, home, sshDir, "SSH_INHERITED_VAR="); got != externalKey { + t.Fatalf("empty override over inherited = %q, want %q", got, externalKey) + } + if got := expandSSHConfigPath("${SSH_CMD_VAR}"+externalKey, home, sshDir, "SSH_CMD_VAR=/cmd", "SSH_CMD_VAR="); got != externalKey { + t.Fatalf("duplicate command entries last empty = %q, want %q", got, externalKey) + } + if got := expandSSHConfigPath("${DEFINITELY_UNSET_VAR_123}/key", home, sshDir); got != "" { + t.Fatalf("unset variable must drop path, got %q", got) + } + + testHome := t.TempDir() + testSSHDir := filepath.Join(testHome, ".ssh") + realKey := filepath.Join(testHome, "real_key") + mustWriteFile(t, realKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nreal\n-----END OPENSSH PRIVATE KEY-----\n") + t.Setenv("SSH_PREFIX", filepath.Join(testHome, "fake_prefix")) + mustWriteFile(t, filepath.Join(testSSHDir, "config"), "IdentityFile ${SSH_PREFIX}"+filepath.ToSlash(realKey)+"\n") + + options := credentialPathOptionsFromEnvironment([]string{testHome}, []string{"HOME=" + testHome, "USERPROFILE=" + testHome, "SSH_PREFIX="}) + creds := credentialDenyReadPathsIn(options, nil) + if len(creds.DiscoveryErrors) != 0 { + t.Fatalf("unexpected discovery errors: %v", creds.DiscoveryErrors) + } + if !denyCovered(creds.Paths, normalizeProfilePath(realKey)) { + t.Fatalf("expected command env empty override to resolve real_key: %v", creds.Paths) + } +} + +func TestSSHInclude_GlobErrorPropagation(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + + absentScanner := &sshDiscovery{} + mustWriteFile(t, filepath.Join(sshDir, "config"), "Include /nonexistent/dir/*.conf\n") + _ = absentScanner.privateKeyDenyCandidates(home) + if len(absentScanner.errors) != 0 { + t.Fatalf("absent optional Include must not produce errors, got: %v", absentScanner.errors) + } + + emptyDir := t.TempDir() + emptyScanner := &sshDiscovery{} + mustWriteFile(t, filepath.Join(sshDir, "config"), fmt.Sprintf("Include %s/*.conf\n", filepath.ToSlash(emptyDir))) + _ = emptyScanner.privateKeyDenyCandidates(home) + if len(emptyScanner.errors) != 0 { + t.Fatalf("empty matching directory must not produce errors, got: %v", emptyScanner.errors) + } + + if runtime.GOOS != "windows" { + unreadableDir := t.TempDir() + mustWriteFile(t, filepath.Join(unreadableDir, "sub.conf"), "IdentityFile /some/key\n") + if err := os.Chmod(unreadableDir, 0o111); err == nil { + defer os.Chmod(unreadableDir, 0o700) + unreadableScanner := &sshDiscovery{} + mustWriteFile(t, filepath.Join(sshDir, "config"), fmt.Sprintf("Include %s/*.conf\n", filepath.ToSlash(unreadableDir))) + _ = unreadableScanner.privateKeyDenyCandidates(home) + if len(unreadableScanner.errors) == 0 { + t.Fatalf("unreadable Include directory must report discovery errors") + } + } + } + + faultScanner := &sshDiscovery{} + testSSHIncludeGlobHook = func(dir string) error { + return os.ErrPermission + } + defer func() { testSSHIncludeGlobHook = nil }() + + mustWriteFile(t, filepath.Join(sshDir, "config"), fmt.Sprintf("Include %s/*.conf\n", filepath.ToSlash(emptyDir))) + _ = faultScanner.privateKeyDenyCandidates(home) + if len(faultScanner.errors) == 0 { + t.Fatalf("expected fault-injected glob failure to produce discovery error") + } + + options := credentialPathOptions{ + Homes: []string{home}, + SSHEnvironment: nil, + } + creds := credentialDenyReadPathsIn(options, nil) + if len(creds.DiscoveryErrors) == 0 { + t.Fatalf("expected discovery error to propagate into profile.DiscoveryErrors") + } +} diff --git a/internal/sandbox/ssh_gpg_deny_unix_test.go b/internal/sandbox/ssh_gpg_deny_unix_test.go new file mode 100644 index 000000000..2e4acc2fa --- /dev/null +++ b/internal/sandbox/ssh_gpg_deny_unix_test.go @@ -0,0 +1,96 @@ +//go:build unix + +package sandbox + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestSSHKeyDiscoverySkipsFIFOAndDeviceWithoutBlocking(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + fifoKey := filepath.Join(sshDir, "custom-key") + if err := syscall.Mkfifo(fifoKey, 0o600); err != nil { + t.Fatalf("Mkfifo custom-key: %v", err) + } + fifoConfig := filepath.Join(sshDir, "config") + if err := syscall.Mkfifo(fifoConfig, 0o600); err != nil { + t.Fatalf("Mkfifo config: %v", err) + } + device := filepath.Join(sshDir, "custom-device") + deviceCreated := syscall.Mknod(device, syscall.S_IFCHR|0o600, 0) == nil + + done := make(chan []string, 1) + go func() { + done <- credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, nil).Paths + }() + var denied []string + select { + case denied = <-done: + case <-time.After(5 * time.Second): + t.Fatal("SSH/GPG discovery blocked on a FIFO or device") + } + + if denyCovered(denied, fifoKey) { + t.Fatalf("FIFO ~/.ssh/custom-key was denied; special files are not key material: %v", denied) + } + if deviceCreated && denyCovered(denied, device) { + t.Fatalf("device ~/.ssh/custom-device was denied: %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} + +func TestSSHConfigDiscoverySkipsSymlinkToFIFOWithoutBlocking(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(t.TempDir(), "fifo-config") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatalf("Mkfifo fifo-config: %v", err) + } + if err := os.Symlink(fifo, filepath.Join(sshDir, "config")); err != nil { + t.Fatal(err) + } + workKey := filepath.Join(home, "keys", "work_ed25519") + if err := os.MkdirAll(filepath.Dir(workKey), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(workKey, nil, 0o600); err != nil { + t.Fatal(err) + } + + done := make(chan []string, 1) + go func() { + done <- credentialDenyReadPathsIn(credentialPathOptions{ + Homes: []string{home}, + ConfigDirs: []string{filepath.Join(home, ".config")}, + }, nil).Paths + }() + var denied []string + select { + case denied = <-done: + case <-time.After(5 * time.Second): + t.Fatal("SSH config discovery blocked on a FIFO behind a config symlink") + } + + if denyCovered(denied, workKey) { + t.Fatalf("IdentityFile was discovered through a FIFO config symlink: %v", denied) + } + if denyCovered(denied, sshDir) { + t.Fatalf("~/.ssh was denied wholesale") + } +} diff --git a/internal/sandbox/ssh_inspect_flags_other.go b/internal/sandbox/ssh_inspect_flags_other.go new file mode 100644 index 000000000..eb94b5566 --- /dev/null +++ b/internal/sandbox/ssh_inspect_flags_other.go @@ -0,0 +1,6 @@ +//go:build !unix + +package sandbox + +// Automatic SSH credential discovery is disabled on Windows. +const sshInspectionNonblock = 0 diff --git a/internal/sandbox/ssh_inspect_linux.go b/internal/sandbox/ssh_inspect_linux.go new file mode 100644 index 000000000..2b9372dff --- /dev/null +++ b/internal/sandbox/ssh_inspect_linux.go @@ -0,0 +1,32 @@ +package sandbox + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// O_PATH pins the actual object without opening a FIFO or device for I/O. +// Reopening its procfs descriptor after fstat keeps the inspected inode even +// when any ancestor or the final pathname is concurrently replaced. +func openSSHInspectionFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + pinned := os.NewFile(uintptr(fd), path) + defer pinned.Close() + return openPinnedSSHInspectionFile(pinned) +} + +func openPinnedSSHInspectionFile(pinned *os.File) (*os.File, error) { + info, err := pinned.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("SSH inspection requires a regular file") + } + return os.Open(fmt.Sprintf("/proc/self/fd/%d", pinned.Fd())) +} diff --git a/internal/sandbox/ssh_inspect_linux_test.go b/internal/sandbox/ssh_inspect_linux_test.go new file mode 100644 index 000000000..df0599ff8 --- /dev/null +++ b/internal/sandbox/ssh_inspect_linux_test.go @@ -0,0 +1,50 @@ +package sandbox + +import ( + "io" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func TestSSHInspectionReadsPinnedFileAfterAncestorReplacement(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "keys") + path := filepath.Join(dir, "config") + mustWriteFile(t, path, "IdentityFile ~/original-key\n") + fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + pinned := os.NewFile(uintptr(fd), path) + t.Cleanup(func() { pinned.Close() }) + if err := os.Rename(dir, filepath.Join(root, "moved")); err != nil { + t.Fatal(err) + } + mustWriteFile(t, path, "IdentityFile ~/replacement-key\n") + f, err := openPinnedSSHInspectionFile(pinned) + if err != nil { + t.Fatal(err) + } + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(data) != "IdentityFile ~/original-key\n" { + t.Fatalf("read replacement instead of pinned file: %q", data) + } +} + +func TestSSHInspectionRejectsPinnedFIFO(t *testing.T) { + path := filepath.Join(t.TempDir(), "fifo") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + if f, err := openSSHInspectionFile(path); err == nil { + f.Close() + t.Fatal("opened FIFO for SSH inspection") + } +} diff --git a/internal/sandbox/ssh_inspect_other.go b/internal/sandbox/ssh_inspect_other.go new file mode 100644 index 000000000..2396e4be2 --- /dev/null +++ b/internal/sandbox/ssh_inspect_other.go @@ -0,0 +1,21 @@ +//go:build !unix + +package sandbox + +import ( + "fmt" + "os" +) + +func openSSHInspectionFile(path string) (*os.File, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("SSH inspection requires a regular file") + } + // Nonblocking open prevents a replacement FIFO from waiting for a writer. + // The caller checks the opened descriptor again before reading any bytes. + return os.OpenFile(path, os.O_RDONLY|sshInspectionNonblock, 0) +} diff --git a/internal/sandbox/ssh_inspect_unix.go b/internal/sandbox/ssh_inspect_unix.go new file mode 100644 index 000000000..48ee7b7a3 --- /dev/null +++ b/internal/sandbox/ssh_inspect_unix.go @@ -0,0 +1,54 @@ +//go:build unix && !linux + +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +func openSSHInspectionFile(path string) (*os.File, error) { + // SSH deliberately follows configured links. Resolve their intended target, + // then forbid links in EVERY component of the actual open. A concurrent + // redirect fails instead of redirecting inspection into a device or FIFO. + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + resolved, err = filepath.Abs(resolved) + if err != nil { + return nil, err + } + info, err := os.Lstat(resolved) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("SSH inspection requires a regular file") + } + parts := strings.Split(strings.TrimPrefix(resolved, "/"), "/") + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + defer func() { _ = unix.Close(fd) }() + for _, part := range parts[:len(parts)-1] { + next, err := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + _ = unix.Close(fd) + fd = next + } + // A regular file may also be replaced by a FIFO without a symlink. Never + // wait for its writer; readRegularFileBounded checks this descriptor's type. + fileFD, err := unix.Openat(fd, parts[len(parts)-1], unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fileFD), path), nil +} diff --git a/internal/sandbox/ssh_key_deny.go b/internal/sandbox/ssh_key_deny.go new file mode 100644 index 000000000..5579c2710 --- /dev/null +++ b/internal/sandbox/ssh_key_deny.go @@ -0,0 +1,788 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// sshConfigMaxIncludeDepth bounds Include recursion. Cycles terminate normally; +// unreadable inputs and exceeded limits make the profile refuse execution. +const sshConfigMaxIncludeDepth = 16 + +const sshConfigMaxBytes = 1 << 20 + +const sshIncludeMatchCap = 64 + +// Page directory reads so busy SSH directories do not require one large +// allocation or become incomplete merely because they contain many entries. +const sshPrivateKeyWalkPageSize = 256 + +const sshPrivateKeySniffBytes = 128 + +// sshSymlinkMaxDirs and sshSymlinkMaxEntries bound traversal of directory +// trees reached through symlinks inside ~/.ssh. Real ~/.ssh directory trees +// remain unbounded. +const sshSymlinkMaxDirs = 64 + +const sshSymlinkMaxEntries = 1024 + +// sshWellKnownPrivateKeyNames are the OpenSSH default private-key basenames. +// They are emitted even when ~/.ssh is absent so pathname-policy backends can +// reserve them; mount-based Linux must refuse unprotected future key paths. +var sshWellKnownPrivateKeyNames = []string{ + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_ecdsa_sk", + "id_ed25519_sk", +} + +// sshKeyMaterialDirectives are directives whose values name key material: +// denied unless an explicit exemption applies. +var sshKeyMaterialDirectives = map[string]bool{ + "certificatefile": true, + "identityfile": true, + "revokedhostkeys": true, +} + +// sshSupportDirectives are directives whose values name support files and sockets: +// denied only when content-sniffing identifies a private key payload, so custom +// known-hosts names, control sockets, and agent sockets keep working. +var sshSupportDirectives = map[string]bool{ + "controlpath": true, + "globalknownhostsfile": true, + "identityagent": true, + "userknownhostsfile": true, +} + +// sshPrivateKeyDenyCandidates returns deny-read candidates for SSH private key +// material under home. ~/.ssh itself is not denied: config, known_hosts, and +// *.pub stay readable so git host resolution still works. Keys named outside +// ~/.ssh are discovered by parsing ~/.ssh/config (and Include) for IdentityFile +// and the other path-valued directives. +type sshDiscovery struct { + errors []string + env []string + workingDir string +} + +func (s *sshDiscovery) fail(path, reason string) { + s.errors = append(s.errors, fmt.Sprintf("SSH discovery incomplete for %s: %s", path, reason)) +} + +func (s *sshDiscovery) privateKeyDenyCandidates(home string) []string { + home = strings.TrimSpace(home) + if home == "" { + return nil + } + sshDir := filepath.Join(home, ".ssh") + var candidates []string + for _, name := range sshWellKnownPrivateKeyNames { + candidates = append(candidates, filepath.Join(sshDir, name)) + } + candidates = append(candidates, s.walkPrivateKeyFiles(sshDir)...) + candidates = append(candidates, s.collectConfigPaths(filepath.Join(sshDir, "config"), home, sshDir, make(map[string]bool), 0)...) + return candidates +} + +var testSSHWalkChildHook func(dir string) + +func (s *sshDiscovery) walkPrivateKeyFiles(sshDir string) []string { + type pendingDir struct { + path string + viaSymlink bool + } + + var out []string + visitedDirs := make(map[string]bool) + pending := []pendingDir{{path: sshDir, viaSymlink: false}} + var symlinkDirsVisited int + var symlinkEntriesSeen int + + walk := func(item pendingDir) { + dir := item.path + realDir := dir + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + realDir = resolved + } + if visitedDirs[realDir] { + return + } + visitedDirs[realDir] = true + + if item.viaSymlink { + symlinkDirsVisited++ + if symlinkDirsVisited > sshSymlinkMaxDirs { + s.fail(dir, "symlink directory limit exceeded") + return + } + } + + root, err := os.OpenRoot(dir) + if err != nil { + if !os.IsNotExist(err) { + s.fail(dir, err.Error()) + } + return + } + defer root.Close() + rootStat, err := root.Stat(".") + if err != nil { + s.fail(dir, err.Error()) + return + } + d, err := root.Open(".") + if err != nil { + s.fail(dir, err.Error()) + return + } + defer d.Close() + for { + entries, err := d.ReadDir(sshPrivateKeyWalkPageSize) + if err != nil && err != io.EOF { + s.fail(dir, err.Error()) + return + } + if item.viaSymlink { + symlinkEntriesSeen += len(entries) + if symlinkEntriesSeen > sshSymlinkMaxEntries { + s.fail(dir, "symlink entry limit exceeded") + return + } + } + if testSSHWalkChildHook != nil { + testSSHWalkChildHook(dir) + } + // Verify directory identity has not changed since opening root. + dirStat, statErr := os.Stat(dir) + if statErr != nil || !os.SameFile(dirStat, rootStat) { + s.fail(dir, "directory identity changed during inspection") + return + } + for _, entry := range entries { + name := entry.Name() + if name == "." || name == ".." { + continue + } + info, err := root.Lstat(name) + if err != nil { + s.fail(filepath.Join(dir, name), err.Error()) + continue + } + path := filepath.Join(dir, name) + mode := info.Mode() + if mode.Type() == os.ModeSymlink { + target, err := root.Readlink(name) + if err != nil { + s.fail(path, err.Error()) + continue + } + targetPath := target + if !filepath.IsAbs(targetPath) { + targetPath = filepath.Join(dir, target) + } + targetStat, err := os.Stat(targetPath) + if err == nil && targetStat.IsDir() { + pending = append(pending, pendingDir{path: targetPath, viaSymlink: true}) + continue + } + // Inspect leaf symlinks (bounded, specials rejected) so a + // custom-named link to a PEM/OpenSSH key is still denied. + if isSSHPrivateKeyFileName(name) || isSSHPrivateKeyFileName(filepath.Base(targetPath)) || s.fileLooksLikePrivateKey(targetPath) { + out = append(out, path) + if targetPath != path { + out = append(out, targetPath) + } + } + continue + } + if info.IsDir() { + pending = append(pending, pendingDir{path: path, viaSymlink: item.viaSymlink}) + continue + } + if !mode.IsRegular() { + continue + } + if isSSHPrivateKeyFileName(name) || s.rootFileLooksLikePrivateKey(root, name, path) { + out = append(out, path) + } + } + if err == io.EOF { + return + } + } + } + // Iteration keeps open directory handles and call-stack depth constant even + // for deeply nested layouts. The physical-path set still breaks link cycles. + for len(pending) > 0 { + item := pending[len(pending)-1] + pending = pending[:len(pending)-1] + walk(item) + } + return out +} + +func isSSHPrivateKeyFileName(name string) bool { + if sshPublicOrConfigName(name) { + return false + } + if strings.HasPrefix(name, "id_") { + return true + } + lower := strings.ToLower(name) + return strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".ppk") +} + +func sshPublicOrConfigName(name string) bool { + switch name { + case "config", "authorized_keys", "authorized_keys2": + return true + } + if strings.HasSuffix(name, ".pub") { + return true + } + return sshKnownHostsFamilyName(name) +} + +// sshKnownHostsFamilyName reports the supported OpenSSH known-hosts filenames +// that must stay readable so git host resolution still works. Arbitrary +// known_hosts.* / ssh_known_hosts.* names are not included: a private key +// named known_hosts.private must still be detected. /dev/null is exempted in +// sshShouldDenyReferencedPath, not here (its basename is "null"). +func sshKnownHostsFamilyName(name string) bool { + switch name { + case "known_hosts", "known_hosts2", "known_hosts.old", + "ssh_known_hosts", "ssh_known_hosts2": + return true + } + return false +} + +func (s *sshDiscovery) rootFileLooksLikePrivateKey(root *os.Root, name, path string) bool { + f, err := root.Open(name) + if err != nil { + s.fail(path, err.Error()) + return false + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return false + } + data, err := io.ReadAll(io.LimitReader(f, sshPrivateKeySniffBytes)) + if err != nil { + s.fail(path, err.Error()) + return false + } + content := strings.TrimSpace(string(data)) + if strings.HasPrefix(content, "PuTTY-User-Key-File") { + return true + } + if !strings.HasPrefix(content, "-----BEGIN ") { + return false + } + return strings.Contains(content, "PRIVATE KEY") +} + +func (s *sshDiscovery) fileLooksLikePrivateKey(path string) bool { + // Always sniff. IdentityFile ~/keys/config (or authorized_keys / *.pub / + // known_hosts) can hold a PEM/OpenSSH/PuTTY private-key payload and must + // not stay readable. Real config, authorized_keys, public keys, and + // known-hosts files do not match these headers, so name-only exemptions + // in sshShouldDenyReferencedPath still keep genuine support files readable. + data, ok := readRegularFileBounded(path, sshPrivateKeySniffBytes) + if !ok { + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() { + s.fail(path, "cannot inspect potential private key") + } else if err != nil && !os.IsNotExist(err) { + s.fail(path, err.Error()) + } + return false + } + content := strings.TrimSpace(string(data)) + if strings.HasPrefix(content, "PuTTY-User-Key-File") { + return true + } + if !strings.HasPrefix(content, "-----BEGIN ") { + return false + } + return strings.Contains(content, "PRIVATE KEY") +} + +// readRegularFileBounded reads only from an inspected regular-file descriptor. +// SSH paths intentionally may reference files outside ~/.ssh; this is file-type +// validation, not a claim that path resolution is contained inside that tree. +func readRegularFileBounded(path string, maxBytes int) ([]byte, bool) { + if maxBytes <= 0 { + return nil, false + } + f, err := openSSHInspectionFile(path) + if err != nil { + return nil, false + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, false + } + data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes))) + if err != nil { + return nil, false + } + return data, true +} + +func (s *sshDiscovery) collectConfigPaths(path, home, sshDir string, seen map[string]bool, depth int) []string { + if depth > sshConfigMaxIncludeDepth { + s.fail(path, "config Include depth limit exceeded") + return nil + } + identity := sshConfigIdentity(path) + if identity == "" || seen[identity] { + return nil + } + seen[identity] = true + + data, ok := readRegularFileBounded(path, sshConfigMaxBytes+1) + if !ok { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + s.fail(path, "cannot read regular config file") + } + return nil + } + if len(data) > sshConfigMaxBytes { + s.fail(path, "config size limit exceeded") + data = data[:sshConfigMaxBytes] + } + + var out []string + for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + key, values := parseSSHDirective(line) + if key == "" || len(values) == 0 { + continue + } + if key == "include" { + for _, pattern := range values { + for _, include := range s.includePaths(pattern, home, sshDir) { + out = append(out, s.collectConfigPaths(include, home, sshDir, seen, depth+1)...) + } + } + continue + } + keyMaterial := sshKeyMaterialDirectives[key] + if !keyMaterial && !sshSupportDirectives[key] { + continue + } + for _, raw := range values { + expanded := expandSSHConfigPath(raw, home, s.effectiveWorkingDir(sshDir), s.env...) + if expanded == "" { + continue + } + if !keyMaterial { + if s.fileLooksLikePrivateKey(expanded) { + out = append(out, expanded) + } + continue + } + if !s.shouldDenyReferencedPath(expanded, home, sshDir) { + continue + } + out = append(out, expanded) + } + } + return out +} + +func (s *sshDiscovery) effectiveWorkingDir(fallback string) string { + if strings.TrimSpace(s.workingDir) != "" { + return s.workingDir + } + return fallback +} + +func sshConfigIdentity(path string) string { + if n := normalizeProfilePath(path); n != "" { + return n + } + cleaned := filepath.Clean(path) + if cleaned == "." || cleaned == "" { + return "" + } + return cleaned +} + +var testSSHIncludeGlobHook func(dir string) error + +func (s *sshDiscovery) includePaths(pattern, home, sshDir string) []string { + expanded := expandSSHConfigPath(pattern, home, sshDir, s.env...) + if expanded == "" { + return nil + } + matches := s.globIncludePaths(expanded) + if len(matches) == 0 { + return nil + } + if len(matches) > sshIncludeMatchCap { + s.fail(expanded, "config Include match limit exceeded") + return nil + } + return matches +} + +func hasGlobMagic(path string) bool { + return strings.ContainsAny(path, "*?[]") +} + +func cleanGlobDir(dir string) string { + if dir == "" { + return "." + } + cleaned := filepath.Clean(dir) + if cleaned == "" { + return "." + } + return cleaned +} + +func (s *sshDiscovery) globIncludePaths(pattern string) []string { + if !hasGlobMagic(pattern) { + if testSSHIncludeGlobHook != nil { + if err := testSSHIncludeGlobHook(pattern); err != nil { + s.fail(pattern, err.Error()) + return nil + } + } + info, err := os.Lstat(pattern) + if err != nil { + if os.IsNotExist(err) { + return nil + } + s.fail(pattern, err.Error()) + return nil + } + if info.IsDir() { + return nil + } + return []string{pattern} + } + + dir, file := filepath.Split(pattern) + dir = cleanGlobDir(dir) + + var parentDirs []string + if hasGlobMagic(dir) { + parentDirs = s.globIncludePaths(dir) + } else { + parentDirs = []string{dir} + } + + var matches []string + for _, parent := range parentDirs { + if testSSHIncludeGlobHook != nil { + if err := testSSHIncludeGlobHook(parent); err != nil { + s.fail(parent, err.Error()) + continue + } + } + fi, err := os.Stat(parent) + if err != nil { + if os.IsNotExist(err) { + continue + } + s.fail(parent, err.Error()) + continue + } + if !fi.IsDir() { + continue + } + f, err := os.Open(parent) + if err != nil { + if os.IsNotExist(err) { + continue + } + s.fail(parent, err.Error()) + continue + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil && err != io.EOF { + s.fail(parent, err.Error()) + continue + } + for _, name := range names { + matched, err := filepath.Match(file, name) + if err != nil { + s.fail(pattern, err.Error()) + return nil + } + if matched { + matches = append(matches, filepath.Join(parent, name)) + } + } + } + return matches +} + +func parseSSHDirective(line string) (string, []string) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", nil + } + tokens := splitSSHTokens(line) + if len(tokens) == 0 { + return "", nil + } + first := tokens[0] + rest := tokens[1:] + if i := strings.IndexByte(first, '='); i > 0 { + val := first[i+1:] + first = first[:i] + if val != "" { + rest = append([]string{val}, rest...) + } + } else if len(rest) > 0 { + if rest[0] == "=" { + rest = rest[1:] + } else if strings.HasPrefix(rest[0], "=") { + rest[0] = rest[0][1:] + if rest[0] == "" { + rest = rest[1:] + } + } + } + key := strings.ToLower(first) + if key == "" || len(rest) == 0 { + return "", nil + } + return key, rest +} + +func splitSSHTokens(s string) []string { + var out []string + var cur strings.Builder + inQuote := byte(0) + flush := func() { + if cur.Len() == 0 { + return + } + out = append(out, cur.String()) + cur.Reset() + } + for i := 0; i < len(s); i++ { + c := s[i] + if inQuote != 0 { + if c == inQuote { + inQuote = 0 + continue + } + if c == '\\' && inQuote == '"' && i+1 < len(s) { + cur.WriteByte(s[i+1]) + i++ + continue + } + cur.WriteByte(c) + continue + } + if c == '\\' && i+1 < len(s) { + cur.WriteByte(s[i+1]) + i++ + continue + } + switch c { + case '\'', '"': + inQuote = c + case ' ', '\t': + flush() + case '#': + if cur.Len() == 0 { + return out + } + cur.WriteByte(c) + default: + cur.WriteByte(c) + } + } + flush() + return out +} + +func expandSSHConfigPath(value, home, sshDir string, env ...string) string { + value = strings.TrimSpace(value) + if value == "" || strings.EqualFold(value, "none") || strings.EqualFold(value, "SSH_AUTH_SOCK") { + return "" + } + // OpenSSH expands environment variables in IdentityFile. ${HOME}/$HOME + // resolves to the supplied home argument. Other variables resolve from the + // supplied environment, falling back to the process environment. Unset or + // invalid $VAR drops the path, like an unsupported token, so discovery never + // follows an unresolved pattern. + expandedEnv, ok := expandSSHConfigPathEnv(value, home, env...) + if !ok { + return "" + } + expanded, ok := expandSSHConfigPathTokens(expandedEnv, home) + if !ok { + return "" + } + value = expanded + switch { + case value == "~": + return filepath.Clean(home) + case strings.HasPrefix(value, "~/"): + return filepath.Join(home, value[2:]) + case strings.HasPrefix(value, "~"): + return "" + case filepath.IsAbs(value): + return filepath.Clean(value) + default: + return filepath.Join(sshDir, value) + } +} + +// expandSSHConfigPathEnv resolves ${VAR} and $VAR. ${HOME} and $HOME resolve +// to the supplied home argument. Other variables prefer the supplied environment +// over the inherited process environment. +// An undefined variable, dangling $, or malformed ${...} drops the path. +func expandSSHConfigPathEnv(value, home string, env ...string) (string, bool) { + if !strings.Contains(value, "$") { + return value, true + } + var b strings.Builder + b.Grow(len(value) + len(home)) + for i := 0; i < len(value); i++ { + if value[i] != '$' { + b.WriteByte(value[i]) + continue + } + if i+1 >= len(value) { + return "", false + } + var name string + if value[i+1] == '{' { + end := strings.IndexByte(value[i+2:], '}') + if end < 0 { + return "", false + } + name = value[i+2 : i+2+end] + i += 2 + end + } else { + if !sshEnvVarStart(value[i+1]) { + return "", false + } + j := i + 1 + for j < len(value) && sshEnvVarChar(value[j]) { + j++ + } + name = value[i+1 : j] + i = j - 1 + } + if name == "HOME" { + b.WriteString(home) + } else { + val, ok := sshDiscoveryEnvValue(env, name) + if !ok { + return "", false + } + b.WriteString(val) + } + } + return b.String(), true +} + +// Command overrides use last-entry precedence, including an explicitly empty +// value. Only a missing override falls back to the inherited environment. +func sshDiscoveryEnvValue(env []string, key string) (string, bool) { + for i := len(env) - 1; i >= 0; i-- { + name, value, ok := strings.Cut(env[i], "=") + if ok && name == key { + return value, true + } + } + return os.LookupEnv(key) +} + +func sshEnvVarStart(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' +} + +func sshEnvVarChar(c byte) bool { + return sshEnvVarStart(c) || (c >= '0' && c <= '9') +} + +// expandSSHConfigPathTokens resolves OpenSSH path tokens we can expand without +// a live connection: %d is the supplied local home, %% is a literal %. Any +// remaining percent token (%h, a trailing %, ...) is unsupported and the path +// is dropped so we never deny (or follow) an unresolved pattern. +func expandSSHConfigPathTokens(value, home string) (string, bool) { + if !strings.Contains(value, "%") { + return value, true + } + var b strings.Builder + b.Grow(len(value) + len(home)) + for i := 0; i < len(value); i++ { + if value[i] != '%' { + b.WriteByte(value[i]) + continue + } + if i+1 >= len(value) { + return "", false + } + switch value[i+1] { + case '%': + b.WriteByte('%') + case 'd': + b.WriteString(home) + default: + return "", false + } + i++ + } + return b.String(), true +} + +func sshShouldDenyReferencedPath(path, home, sshDir string) bool { + return (&sshDiscovery{}).shouldDenyReferencedPath(path, home, sshDir) +} + +func (s *sshDiscovery) shouldDenyReferencedPath(path, home, sshDir string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + cleaned := filepath.Clean(path) + if cleaned == string(filepath.Separator) { + return false + } + if home != "" && cleaned == filepath.Clean(home) { + return false + } + if sshDir != "" && cleaned == filepath.Clean(sshDir) { + return false + } + if sshIsDevNullPath(cleaned) { + return false + } + // Sniff before the public-name exemption so IdentityFile ~/keys/work.pub + // (or a relocated key named config / authorized_keys / known_hosts) with a + // private-key payload is denied. Genuine public keys, genuine known-hosts, + // config, and authorized_keys do not match and stay readable. + if s.fileLooksLikePrivateKey(cleaned) { + return true + } + return !sshPublicOrConfigName(filepath.Base(cleaned)) +} + +// sshIsDevNullPath reports UserKnownHostsFile /dev/null (and the host equivalent +// os.DevNull). The basename of that path is "null", which is not a known-hosts +// name; denying it would install a Seatbelt deny file-read* on /dev/null. +func sshIsDevNullPath(path string) bool { + cleaned := filepath.Clean(path) + if cleaned == os.DevNull || strings.EqualFold(cleaned, os.DevNull) { + return true + } + return filepath.ToSlash(cleaned) == "/dev/null" +} diff --git a/internal/sandbox/ssh_profile_linux_test.go b/internal/sandbox/ssh_profile_linux_test.go new file mode 100644 index 000000000..91e2154f4 --- /dev/null +++ b/internal/sandbox/ssh_profile_linux_test.go @@ -0,0 +1,46 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// Exercise profile construction and command planning together so dropping an +// error between discovery, finalization, and helper serialization is detected. +func TestSSHIncompleteProfileRefusesCommand(t *testing.T) { + for _, kind := range []string{"config size", "config Include match"} { + t.Run(kind, func(t *testing.T) { + home := t.TempDir() + for _, name := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "APPDATA", "LOCALAPPDATA"} { + t.Setenv(name, home) + } + for _, name := range []string{"ZERO_OAUTH_TOKENS_PATH", "ZERO_MCP_OAUTH_TOKENS_PATH", "GNUPGHOME", "ZERO_OAUTH_STORAGE", "CLOUDSDK_CONFIG", "GH_CONFIG_DIR", "DOCKER_CONFIG", "KUBECONFIG", "NETRC", "GOOGLE_APPLICATION_CREDENTIALS", "NPM_CONFIG_USERCONFIG", "npm_config_userconfig"} { + t.Setenv(name, "") + } + sshDir := filepath.Join(home, ".ssh") + if kind == "config size" { + mustWriteFile(t, filepath.Join(sshDir, "config"), strings.Repeat("#", sshConfigMaxBytes+1)) + } else { + mustWriteFile(t, filepath.Join(sshDir, "config"), "Include includes/*\n") + for i := 0; i <= sshIncludeMatchCap; i++ { + mustWriteFile(t, filepath.Join(sshDir, "includes", fmt.Sprintf("%03d", i)), "IdentityFile ~/relocated-key\n") + } + } + workspace := t.TempDir() + profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil) + helper, err := os.Executable() + if err != nil { + t.Fatal(err) + } + _, err = BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{HelperPath: helper, Config: LinuxSandboxHelperConfig{ + PermissionProfile: profile, SandboxPolicyCWD: workspace, CommandCWD: workspace, Command: []string{"true"}, + }}) + if err == nil || !strings.Contains(err.Error(), kind+" limit exceeded") { + t.Fatalf("incomplete %s discovery allowed command planning: %v", kind, err) + } + }) + } +}