diff --git a/internal/cli/sandbox_test.go b/internal/cli/sandbox_test.go index 53ffd7b4d..5891324f6 100644 --- a/internal/cli/sandbox_test.go +++ b/internal/cli/sandbox_test.go @@ -561,27 +561,38 @@ 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 if runtime.GOOS != "windows" { - if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil { - credentialHome = resolved + homes := []string{emptyHome} + if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil && resolved != emptyHome { + homes = append(homes, resolved) } - wantDenyRead = []string{ - filepath.Join(credentialHome, ".aws"), - filepath.Join(credentialHome, ".azure"), - // 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 - // with `zero sandbox policy --json`. - filepath.Join(credentialHome, ".git-credentials"), - filepath.Join(credentialHome, ".config", "git", "credentials"), - filepath.Join(credentialHome, ".npmrc"), - filepath.Join(credentialHome, ".netrc"), - filepath.Join(credentialHome, ".kube", "config"), - filepath.Join(credentialHome, ".docker", "config.json"), - filepath.Join(credentialHome, ".config", "gh", "hosts.yml"), - filepath.Join(credentialHome, ".config", "gcloud"), - filepath.Join(credentialHome, ".config", "zero"), + for _, credentialHome := range homes { + for _, rel := range []string{ + ".aws", + ".azure", + ".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"), + // 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 + // with `zero sandbox policy --json`. + ".git-credentials", + filepath.Join(".config", "git", "credentials"), + ".npmrc", + ".netrc", + filepath.Join(".kube", "config"), + filepath.Join(".docker", "config.json"), + filepath.Join(".config", "gh", "hosts.yml"), + filepath.Join(".config", "gcloud"), + filepath.Join(".config", "zero"), + } { + wantDenyRead = append(wantDenyRead, filepath.Join(credentialHome, rel)) + } } } gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"]) @@ -593,18 +604,30 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp wantCarveouts := []string(nil) wantEnsureDirs := []string(nil) if runtime.GOOS != "windows" { - zeroDir := filepath.Join(credentialHome, ".config", "zero") - wantCarveouts = []string{ - filepath.Join(zeroDir, "plugins"), - filepath.Join(zeroDir, "specialists"), - filepath.Join(zeroDir, "commands"), + homes := []string{emptyHome} + if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil && resolved != emptyHome { + homes = append(homes, resolved) + } + for _, credentialHome := range homes { + zeroDir := filepath.Join(credentialHome, ".config", "zero") + wantCarveouts = append(wantCarveouts, + filepath.Join(zeroDir, "plugins"), + filepath.Join(zeroDir, "specialists"), + filepath.Join(zeroDir, "commands"), + ) + wantEnsureDirs = append(wantEnsureDirs, zeroDir) } - 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") 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..3ba639e94 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -303,26 +303,28 @@ 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) } + args = appendUnreadableLinuxPaths(args, unreadable, fs.DenyReadCarveouts, fs.WriteRoots) return linuxBwrapFilesystemPlan{ Args: args, ProtectedCreateTargets: dedupeStrings(protectedCreateTargets), @@ -397,14 +399,226 @@ 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 == "" { +// appendUnreadableLinuxPaths emits bwrap args that hide the given deny paths. +// Directories keep the existing tmpfs mask. Regular files stay `--ro-bind +// /dev/null path`. Symlink dests cannot use that bind: mount(2) LOOKUP_FOLLOW +// would mask the current target (so a later retarget reopens a new credential) +// or ENOENT a dangling link. Instead mask the resolved regular-file target and, +// when the parent is a credential directory, tmpfs-overlay the parent omitting +// denied basenames so the lexical dentry disappears without following. +func appendUnreadableLinuxPaths(args []string, paths []string, carveouts []string, writeRoots []WritableRoot) []string { + classified := classifyUnreadableLinuxPaths(paths) + for _, dir := range classified.dirs { + args = appendUnreadableLinuxDirArgs(args, dir, carveouts) + } + omits := linuxDeniedBasenamesByParent(classified.files, classified.links) + seenParents := make(map[string]struct{}) + for _, link := range classified.links { + args = appendUnreadableLinuxResolvedSymlinkArgs(args, link, carveouts) + parent := filepath.Clean(filepath.Dir(link)) + overlayParent := linuxCanonicalDest(parent) + if linuxParentOverlaid(seenParents, overlayParent) { + continue + } + if !linuxCredentialParentSafeToTmpfs(overlayParent, writeRoots) && !linuxCredentialParentSafeToTmpfs(parent, writeRoots) { + continue + } + var applied bool + args, applied = appendLinuxParentTmpfsOmitting(args, overlayParent, omits[overlayParent]) + if applied { + // Record every spelling of the parent only after the overlay is + // actually added. macOS /var vs /private/var (and similar aliases) + // must skip file binds using either form, otherwise --ro-bind + // /dev/null and --tmpfs name different dests for the same directory. + recordLinuxParentSpellings(seenParents, parent) + recordLinuxParentSpellings(seenParents, overlayParent) + } + } + for _, file := range classified.files { + parent := filepath.Clean(filepath.Dir(file)) + if linuxParentOverlaid(seenParents, parent) { + // Parent was already tmpfs-overlaid (symlink sibling in the same + // credential dir). Re-binding /dev/null onto the regular file would + // target a dest that no longer exists after the overlay and can + // abort bubblewrap at startup. + continue + } + args = append(args, "--ro-bind", "/dev/null", file) + } + return args +} + +type linuxUnreadableClassified struct { + 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 == "" { + continue + } + info, err := os.Lstat(inspect) + if err != nil && canonical != "" && canonical != inspect { + info, err = os.Lstat(canonical) + inspect = canonical + } + if err != nil { + continue + } + 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 +} + +func linuxCanonicalDest(path string) string { + path = filepath.Clean(path) + if canonical := normalizeProfilePath(path); canonical != "" { + return canonical + } + return path +} + +// 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 + } +} + +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 linuxParentSpellings(parent string) []string { + parent = filepath.Clean(parent) + seen := make(map[string]struct{}) + var out []string + add := func(path string) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + out = append(out, path) + } + add(parent) + add(normalizeProfilePathLexically(parent)) + add(normalizeProfilePath(parent)) + return out +} + +func recordLinuxParentSpellings(seen map[string]struct{}, parent string) { + for _, spelling := range linuxParentSpellings(parent) { + seen[spelling] = struct{}{} + } +} + +func linuxParentOverlaid(seen map[string]struct{}, parent string) bool { + for _, spelling := range linuxParentSpellings(parent) { + if _, ok := seen[spelling]; ok { + return true + } + } + return false +} + +func linuxDeniedBasenamesByParent(files, links []string) map[string]map[string]struct{} { + out := make(map[string]map[string]struct{}) + add := func(path string) { + parent := linuxCanonicalDest(filepath.Dir(path)) + base := filepath.Base(path) + m, ok := out[parent] + if !ok { + m = make(map[string]struct{}) + out[parent] = m + } + m[base] = struct{}{} + } + for _, path := range files { + add(path) + } + for _, path := range links { + add(path) + } + return out +} + +func appendUnreadableLinuxResolvedSymlinkArgs(args []string, path string, carveouts []string) []string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil || resolved == "" { + return args + } + info, err := os.Lstat(resolved) + if err != nil { return args } - if info, err := os.Stat(path); err == nil && !info.IsDir() { - return append(args, "--ro-bind", "/dev/null", path) + if info.IsDir() { + return appendUnreadableLinuxDirArgs(args, resolved, carveouts) } + return append(args, "--ro-bind", "/dev/null", resolved) +} + +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,13 +630,86 @@ 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) } } return append(args, "--remount-ro", path) } +// linuxCredentialParentSafeToTmpfs reports that parent may be reconstructed +// inside the sandbox to hide a lexical symlink dentry. HOME, `/`, `/tmp`, +// `/etc`, `/var`, and write roots must never be tmpfs-overlaid: reconstructing +// HOME is forbidden and would hide the workspace. Only credential directories +// such as ~/.ssh and ~/.gnupg (including nested dirs under them) qualify. +func linuxCredentialParentSafeToTmpfs(parent string, writeRoots []WritableRoot) bool { + parent = filepath.Clean(parent) + if parent == "" || parent == "." || parent == string(filepath.Separator) { + return false + } + switch parent { + case "/tmp", "/etc", "/var", "/usr", "/home", "/root", "/opt", "/dev", "/proc", "/sys", "/run", "/mnt", "/media": + return false + } + if !linuxCredentialDirPath(parent) { + return false + } + for _, wr := range writeRoots { + root := filepath.Clean(strings.TrimSpace(wr.Root)) + if root != "" && (parent == root || pathWithinRoot(parent, root) || pathWithinRoot(root, parent)) { + return false + } + } + info, err := os.Lstat(parent) + if err != nil || !info.IsDir() { + return false + } + return true +} + +func linuxCredentialDirPath(path string) bool { + base := filepath.Base(filepath.Clean(path)) + switch base { + case ".ssh", ".gnupg", ".aws", ".azure": + return true + } + slash := filepath.ToSlash(filepath.Clean(path)) + for _, marker := range []string{"/.ssh/", "/.gnupg/", "/.aws/", "/.azure/"} { + if strings.Contains(slash, marker) { + return true + } + } + return false +} + +func appendLinuxParentTmpfsOmitting(args []string, parent string, omit map[string]struct{}) ([]string, bool) { + parent = filepath.Clean(parent) + entries, err := os.ReadDir(parent) + if err != nil { + return args, false + } + // 555 keeps option-2 public names (config, known_hosts, *.pub) listable + // after the overlay; denied basenames are simply not rebound. + args = append(args, "--perms", "555", "--tmpfs", parent) + for _, entry := range entries { + name := entry.Name() + if name == "." || name == ".." { + continue + } + if _, skip := omit[name]; skip { + continue + } + sibling := filepath.Join(parent, name) + if !pathExists(sibling) { + // os.ReadDir returns dangling symlinks; bwrap --ro-bind sources + // must resolve, so skip them rather than aborting sandbox startup. + continue + } + args = append(args, "--ro-bind", sibling, sibling) + } + return append(args, "--remount-ro", parent), true +} + // nestedCarveoutPaths returns the carveouts that sit strictly inside root, // shallowest first so a parent bind is created before a nested one. func nestedCarveoutPaths(root string, carveouts []string) []string { @@ -471,6 +758,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/profile.go b/internal/sandbox/profile.go index 349e2b1c6..da662758e 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -439,6 +439,7 @@ func credentialPathOptionsFromEnvironment(baseDirs []string, env []string) crede } return credentialPathOptions{ Homes: homes, + GPGHomes: resolveCredentialOverridePaths(credentialEnvValue(env, "GNUPGHOME"), baseDirs), ConfigDirs: dedupeStrings(configDirs), CloudSDKConfigDirs: dedupeStrings(cloudSDKConfigDirs), GoogleCredentials: resolveCredentialOverridePaths(credentialEnvValue(env, "GOOGLE_APPLICATION_CREDENTIALS"), baseDirs), @@ -466,6 +467,7 @@ func credentialEnvValue(env []string, key string) string { type credentialPathOptions struct { Homes []string + GPGHomes []string ConfigDirs []string CloudSDKConfigDirs []string GoogleCredentials []string @@ -500,25 +502,55 @@ func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string var carveouts []string var ensureDirs []string var dirs []string + var lexicalCandidates []string + var lexicalDirs []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 := sshPrivateKeyDenyCandidates(home) + candidates = append(candidates, gitCredentials) + candidates = append(candidates, 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. + // Use-time handle-relative / openat enforcement is a pre-existing + // backend gap, not introduced here. + 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...) @@ -596,21 +628,75 @@ 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) + } + } + if credentialDirDenyHidesNestedAllow(allowRoots, path) { + continue + } 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)), + 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 + } + if credentialDirDenyHidesNestedAllow(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: // the store, its lock siblings, its encryption-key sibling, and the directory // it publishes new contents through. The names are fixed so an override outside @@ -660,7 +746,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) @@ -679,7 +765,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 } @@ -700,6 +786,69 @@ 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 +} + +// credentialDirDenyHidesNestedAllow reports that some allowRead sits under +// path and cannot be expressed as a directory DenyReadCarveout. Existing +// carveouts only re-bind directories (Zero's plugins/specialists/commands). +// A nested file grant such as $HOME/.gnupg/private-keys-v1.d/keygrip.key +// would stay unreadable if path were still emitted as a directory deny: +// bubblewrap masks the dir and Seatbelt denies the subtree after the read +// rule. In that case the parent dir deny is omitted. +func credentialDirDenyHidesNestedAllow(allowRoots []string, path string) bool { + for _, allow := range credentialNestedAllowReads(allowRoots, path) { + if normalizeCredentialCarveoutPath(allow) == "" { + return true + } + } + return false +} + +// 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. @@ -734,10 +883,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) { @@ -969,6 +1118,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 } @@ -1006,6 +1158,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/runner.go b/internal/sandbox/runner.go index 8528e7e82..a225a4387 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -900,7 +900,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/ssh_gpg_deny_test.go b/internal/sandbox/ssh_gpg_deny_test.go new file mode 100644 index 000000000..2f95076fa --- /dev/null +++ b/internal/sandbox/ssh_gpg_deny_test.go @@ -0,0 +1,1442 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func denyCovered(denied []string, target string) bool { + norm := normalizeProfilePath(target) + for _, entry := range denied { + if entry == norm || pathWithinRoot(entry, 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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + sbpl := strings.Join(denyReadRules(profile.FileSystem), "\n") + for _, candidate := range []string{gnupgLink, gitLink, sshLink} { + lexical := normalizeProfilePathLexically(candidate) + assertBwrapDoesNotFollowBindSymlinkDest(t, args, lexical) + if !strings.Contains(sbpl, sandboxProfileString(lexical)) { + t.Fatalf("Seatbelt rules missing lexical pathname %q:\n%s", lexical, sbpl) + } + } + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", normalizeProfilePath(gitTarget)) + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", normalizeProfilePath(sshTarget)) + sshDir := normalizeProfilePathLexically(filepath.Join(home, ".ssh")) + if !argsContainSequence(args, "--tmpfs", sshDir) { + t.Fatalf("expected tmpfs overlay of ~/.ssh to hide lexical key symlink: %#v", args) + } + gnupgTargetNorm := normalizeProfilePath(gnupgTarget) + if !argsContainSequence(args, "--tmpfs", gnupgTargetNorm) { + t.Fatalf("expected tmpfs mask of resolved ~/.gnupg target: %#v", args) + } + + newGit := filepath.Join(realDir, "other-credentials") + mustWriteFile(t, newGit, "retargeted") + if err := os.Remove(gitLink); err != nil { + t.Fatal(err) + } + mustSymlink(t, newGit, gitLink) + + lexicalGit := normalizeProfilePathLexically(gitLink) + assertBwrapDoesNotFollowBindSymlinkDest(t, args, lexicalGit) + reemitted := linuxBwrapFilesystemArgs(profile) + assertBwrapDoesNotFollowBindSymlinkDest(t, reemitted, lexicalGit) + assertArgsContainSequence(t, reemitted, "--ro-bind", "/dev/null", normalizeProfilePath(newGit)) + + deniedAfter := sshGPGDenied(t, home, nil) + 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 < sshPrivateKeyWalkMaxEntries+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) + } + if !argsContainSequence(args, "--ro-bind", key, key) { + t.Fatalf("bwrap should --ro-bind the carved-out key: %#v", args) + } + if argsContainSequence(args, "--ro-bind", secring, secring) { + 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 assertBwrapDoesNotFollowBindSymlinkDest(t *testing.T, args []string, lexical string) { + t.Helper() + if argsContainSequence(args, "--ro-bind", "/dev/null", lexical) { + t.Fatalf("bwrap --ro-bind /dev/null used symlink dest %q (follows / ENOENTs): %#v", lexical, args) + } +} + +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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + if !argsContainSequence(args, "--perms", "111", "--tmpfs", canonicalGnupg) { + t.Fatalf("bwrap should tmpfs-mask canonical gnupg to protect sibling secrets: %#v", args) + } + if !argsContainSequence(args, "--ro-bind", key, key) { + t.Fatalf("bwrap should --ro-bind carved-out key: %#v", args) + } + if argsContainSequence(args, "--ro-bind", secring, secring) { + t.Fatalf("bwrap unexpectedly rebound secring: %#v", args) + } + + 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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + assertArgsContainSequence(t, args, "--perms", "111", "--tmpfs", canonicalGnupg) + assertArgsContainSequence(t, args, "--ro-bind", canonicalKeyDir, canonicalKeyDir) + assertArgsContainSequence(t, args, "--remount-ro", canonicalGnupg) + bindIdx := argsSequenceIndex(args, "--ro-bind", canonicalKeyDir, canonicalKeyDir) + remountIdx := argsSequenceIndex(args, "--remount-ro", canonicalGnupg) + if bindIdx < 0 || remountIdx < 0 || bindIdx > remountIdx { + t.Fatalf("canonical carveout bind (%d) must precede tmpfs remount-ro (%d): %#v", bindIdx, remountIdx, args) + } + + sbpl := strings.Join(denyReadCarveoutRules(profile.FileSystem), "\n") + keyDirLit := sandboxProfileString(canonicalKeyDir) + if !strings.Contains(sbpl, `(allow file-read* file-test-existence (subpath "`+keyDirLit+`"))`) { + t.Fatalf("Seatbelt carveout rules missing canonical private-keys-v1.d:\n%s", sbpl) + } + full := seatbeltProfileFromPermissionProfile(profile, Policy{}, "") + if !strings.Contains(full, `(allow file-read* file-test-existence (subpath "`+keyDirLit+`"))`) { + 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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + + lexicalLive := normalizeProfilePathLexically(liveLink) + lexicalDangling := normalizeProfilePathLexically(danglingLink) + lexicalGit := normalizeProfilePathLexically(gitLink) + assertBwrapDoesNotFollowBindSymlinkDest(t, args, lexicalLive) + assertBwrapDoesNotFollowBindSymlinkDest(t, args, lexicalDangling) + assertBwrapDoesNotFollowBindSymlinkDest(t, args, lexicalGit) + + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", normalizeProfilePath(liveTarget)) + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", normalizeProfilePath(gitTarget)) + if argsContainSequence(args, "--ro-bind", "/dev/null", danglingTarget) || + argsContainSequence(args, "--ro-bind", "/dev/null", normalizeProfilePath(danglingTarget)) || + argsContainSequence(args, "--ro-bind", "/dev/null", lexicalDangling) { + t.Fatalf("dangling symlink must not be a hard --ro-bind dest: %#v", args) + } + + sshDirLex := normalizeProfilePathLexically(sshDir) + if !argsContainSequence(args, "--tmpfs", sshDirLex) { + t.Fatalf("expected tmpfs overlay of ~/.ssh for live/dangling key symlinks: %#v", args) + } + assertArgsContainSequence(t, args, "--ro-bind", config, config) + assertArgsContainSequence(t, args, "--ro-bind", knownHosts, knownHosts) + assertArgsContainSequence(t, args, "--ro-bind", pub, pub) + if argsContainSequence(args, "--ro-bind", liveLink, liveLink) || + argsContainSequence(args, "--ro-bind", danglingLink, danglingLink) { + t.Fatalf("denied symlink basenames were rebound into ~/.ssh overlay: %#v", args) + } + if argsContainSequence(args, "--tmpfs", home) || argsContainSequence(args, "--tmpfs", filepath.Clean(home)) { + t.Fatalf("HOME must never be tmpfs-overlaid: %#v", args) + } + 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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + sshDirLex := normalizeProfilePathLexically(sshDir) + if !argsContainSequence(args, "--tmpfs", sshDirLex) { + t.Fatalf("expected tmpfs overlay of ~/.ssh once a denied symlink is present: %#v", args) + } + if argsContainSequence(args, "--ro-bind", "/dev/null", idEd) || + argsContainSequence(args, "--ro-bind", "/dev/null", normalizeProfilePath(idEd)) { + t.Fatalf("--ro-bind /dev/null onto regular file whose parent was tmpfs-overlaid: %#v", args) + } + if argsContainSequence(args, "--ro-bind", idEd, idEd) { + t.Fatalf("denied regular key was rebound into ~/.ssh overlay: %#v", args) + } + if argsContainSequence(args, "--ro-bind", danglingSibling, danglingSibling) { + t.Fatalf("dangling sibling used as --ro-bind source: %#v", args) + } + assertArgsContainSequence(t, args, "--ro-bind", config, config) + 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, + }, + } + args := linuxBwrapFilesystemArgs(profile) + sshDirLex := normalizeProfilePathLexically(sshDir) + if argsContainSequence(args, "--tmpfs", sshDirLex) { + t.Fatalf("overlay must not apply when parent ReadDir fails: %#v", args) + } + assertArgsContainSequence(t, args, "--ro-bind", "/dev/null", idEd) + 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 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 + if !denyListedExact(creds.Carveouts, publicFile) && !denyCovered(creds.Carveouts, publicFile) { + 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, 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 TestLinuxHelperCredentialParentTmpfsRejectsNestedWriteRoots(t *testing.T) { + home := t.TempDir() + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + writeRoots := []WritableRoot{ + {Root: filepath.Join(sshDir, "project")}, + } + if linuxCredentialParentSafeToTmpfs(sshDir, writeRoots) { + t.Fatal("expected linuxCredentialParentSafeToTmpfs to reject parent containing nested write root") + } +} 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_key_deny.go b/internal/sandbox/ssh_key_deny.go new file mode 100644 index 000000000..8850a3c21 --- /dev/null +++ b/internal/sandbox/ssh_key_deny.go @@ -0,0 +1,552 @@ +package sandbox + +import ( + "io" + "os" + "path/filepath" + "strings" +) + +// sshConfigMaxIncludeDepth bounds Include recursion. Unreadable or cyclic +// includes are skipped rather than failing the profile build. +const sshConfigMaxIncludeDepth = 16 + +const sshConfigMaxBytes = 1 << 20 + +const sshIncludeMatchCap = 64 + +// sshPrivateKeyWalkMaxDepth bounds recursive discovery under ~/.ssh. Nested +// directories such as ~/.ssh/keys are walked; directory symlinks are not +// followed, so a cycle cannot hang profile construction. +const sshPrivateKeyWalkMaxDepth = 8 + +// sshPrivateKeyWalkMaxEntries is a per-directory cap on entries considered +// under ~/.ssh. Extra entries in one directory (a large known_hosts.d, for +// example) are skipped; walking continues in sibling and parent directories +// so a private key elsewhere is still discovered. It is not a process-wide +// abort that unwinds the whole tree. +const sshPrivateKeyWalkMaxEntries = 256 + +const sshPrivateKeySniffBytes = 128 + +// 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 still masks only paths that exist. +var sshWellKnownPrivateKeyNames = []string{ + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_ecdsa_sk", + "id_ed25519_sk", +} + +// sshPathValuedDirectives are ssh_config keywords whose values name files or +// sockets. IdentityFile is the important one for relocated keys; the rest are +// collected so a CertificateFile or RevokedHostKeys path outside ~/.ssh is not +// left readable. UserKnownHostsFile / GlobalKnownHostsFile values that resolve +// to known_hosts are dropped later so option 2 keeps host resolution working. +var sshPathValuedDirectives = map[string]bool{ + "certificatefile": true, + "controlpath": true, + "globalknownhostsfile": true, + "identityagent": true, + "identityfile": true, + "revokedhostkeys": 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. +func sshPrivateKeyDenyCandidates(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, walkSSHPrivateKeyFiles(sshDir)...) + candidates = append(candidates, sshConfigReferencedPaths(home, sshDir)...) + return candidates +} + +func walkSSHPrivateKeyFiles(sshDir string) []string { + var out []string + visitedDirs := make(map[string]bool) + var walk func(dir string, depth int) + walk = func(dir string, depth int) { + if depth > sshPrivateKeyWalkMaxDepth { + return + } + realDir := dir + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + realDir = resolved + } + if visitedDirs[realDir] { + return + } + visitedDirs[realDir] = true + + d, err := os.Open(dir) + if err != nil { + return + } + // Bound allocation to the per-directory cap. os.ReadDir would load the + // whole directory first. Overflow of one dir must not abort siblings. + entries, err := d.ReadDir(sshPrivateKeyWalkMaxEntries) + _ = d.Close() + if err != nil && err != io.EOF { + return + } + n := 0 + for _, entry := range entries { + if n >= sshPrivateKeyWalkMaxEntries { + // Skip the rest of this directory only; sibling dirs still walk. + break + } + name := entry.Name() + if name == "." || name == ".." { + continue + } + path := filepath.Join(dir, name) + n++ + info, err := os.Lstat(path) + if err != nil { + continue + } + mode := info.Mode() + if mode.Type() == os.ModeSymlink { + targetStat, err := os.Stat(path) + if err == nil && targetStat.IsDir() { + walk(path, depth+1) + continue + } + // Inspect leaf symlinks (bounded, specials rejected) so a + // custom-named link to a PEM/OpenSSH key is still denied. + if isSSHPrivateKeyFileName(name) || sshFileLooksLikePrivateKey(path) { + out = append(out, path) + } + continue + } + if info.IsDir() { + walk(path, depth+1) + continue + } + if !mode.IsRegular() { + continue + } + if isSSHPrivateKeyFileName(name) || sshFileLooksLikePrivateKey(path) { + out = append(out, path) + } + } + } + walk(sshDir, 0) + 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 sshFileLooksLikePrivateKey(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 { + return false + } + s := strings.TrimSpace(string(data)) + if strings.HasPrefix(s, "PuTTY-User-Key-File") { + return true + } + if !strings.HasPrefix(s, "-----BEGIN ") { + return false + } + return strings.Contains(s, "PRIVATE KEY") +} + +// readRegularFileBounded Lstats first and refuses FIFOs, devices, and +// sockets so profile construction cannot block on a special file. Regular-file +// symlinks are followed: OpenSSH reads ~/.ssh/config and Include targets +// through them, so a relocated IdentityFile would otherwise stay readable. +// The resolved path is Lstat'd again and opened (bounded LimitReader) so a +// FIFO or device behind the link is never opened. +func readRegularFileBounded(path string, maxBytes int) ([]byte, bool) { + if maxBytes <= 0 { + return nil, false + } + info, err := os.Lstat(path) + if err != nil { + return nil, false + } + readPath := path + if info.Mode().Type() == os.ModeSymlink { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, false + } + info, err = os.Lstat(resolved) + if err != nil { + return nil, false + } + readPath = resolved + } + if !info.Mode().IsRegular() { + return nil, false + } + f, err := os.Open(readPath) + if err != nil { + return nil, false + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes))) + if err != nil { + return nil, false + } + return data, true +} + +func sshConfigReferencedPaths(home, sshDir string) []string { + return collectSSHConfigPaths(filepath.Join(sshDir, "config"), home, sshDir, make(map[string]bool), 0) +} + +func collectSSHConfigPaths(path, home, sshDir string, seen map[string]bool, depth int) []string { + if depth > sshConfigMaxIncludeDepth { + return nil + } + identity := sshConfigIdentity(path) + if identity == "" || seen[identity] { + return nil + } + seen[identity] = true + + data, ok := readRegularFileBounded(path, sshConfigMaxBytes) + if !ok { + return nil + } + + 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 sshIncludePaths(pattern, home, sshDir) { + out = append(out, collectSSHConfigPaths(include, home, sshDir, seen, depth+1)...) + } + } + continue + } + if !sshPathValuedDirectives[key] { + continue + } + for _, raw := range values { + expanded := expandSSHConfigPath(raw, home, sshDir) + if !sshShouldDenyReferencedPath(expanded, home, sshDir) { + continue + } + out = append(out, expanded) + } + } + return out +} + +func sshConfigIdentity(path string) string { + if n := normalizeProfilePath(path); n != "" { + return n + } + cleaned := filepath.Clean(path) + if cleaned == "." || cleaned == "" { + return "" + } + return cleaned +} + +func sshIncludePaths(pattern, home, sshDir string) []string { + expanded := expandSSHConfigPath(pattern, home, sshDir) + if expanded == "" { + return nil + } + matches, err := filepath.Glob(expanded) + if err != nil || len(matches) == 0 { + return nil + } + if len(matches) > sshIncludeMatchCap { + matches = matches[:sshIncludeMatchCap] + } + 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 { + rest = append([]string{first[i+1:]}, rest...) + first = first[:i] + 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 '#': + flush() + return out + default: + cur.WriteByte(c) + } + } + flush() + return out +} + +func expandSSHConfigPath(value, home, sshDir 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 + // process environment. Unset or invalid $VAR is treated like an unsupported + // token: drop the path so we never deny or follow an unresolved pattern. + expandedEnv, ok := expandSSHConfigPathEnv(value, home) + 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 resolve from the environment. +// An undefined variable, dangling $, or malformed ${...} drops the path. +func expandSSHConfigPathEnv(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 + } + 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 := os.Getenv(name) + if val == "" { + return "", false + } + b.WriteString(val) + } + } + return b.String(), true +} + +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 { + 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 sshFileLooksLikePrivateKey(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" +}