mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat(sandbox): allow opt-out of mandatory deny via explicit allow rules
Mandatory deny patterns (.env, .aws, .ssh, .gcloud, .kube, .gnupg,
.docker/config.json, .git/config) can now be opted out by listing the
exact literal post-expansion path in policy filesystem.allow_read /
allow_write, OR via --sandbox-allow read=... / write=... at runtime.
Both channels are treated at par.
Suppression is exact-match. Listing the CWD-absolute or HOME-absolute
form of a dangerous file additionally suppresses its **/<file> glob
sibling on the same direction so a single opt-out is sufficient.
Broad globs (${CWD}/**) and relative paths in user allow lists do not
suppress. The unnamed absolute form remains denied. .git/hooks is
unconditional and never suppressible (arbitrary code execution risk).
GetMandatoryDenyPatterns now returns split DenyRead / DenyWrite
slices and reports SuppressedRead / SuppressedWrite for audit. Both
translators emit per-direction deny rules and log.Warnf each
suppression. On Linux/bubblewrap, the tmpfs hide is restricted to the
intersection of DenyRead and DenyWrite; one-sided suppression falls
back to /dev/null (write) or the user's allow_read --ro-bind (read).
bwrap has no primitive that allows writes while denying reads, so
write-only opt-outs warn that the read-side mandatory deny is
unenforceable.
Updates docs/sandbox.md to document the opt-out, exact-match
semantics, and the Linux platform limitation. Updates pmg-e2e.yml to
create ./.env so the sandbox e2e test exercises the BLOCK case.
Closes #232
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: Code review fixes
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
4.2 KiB
Go
153 lines
4.2 KiB
Go
package util
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// DANGEROUS_FILES are credential and config files blocked by default.
|
|
// Users opt out via allow_read / allow_write (see GetMandatoryDenyPatterns).
|
|
var DANGEROUS_FILES = []string{
|
|
".env",
|
|
".env.*",
|
|
".aws",
|
|
".azure",
|
|
".gcloud",
|
|
".config/gcloud",
|
|
".kube",
|
|
".ssh",
|
|
".gnupg",
|
|
".docker/config.json",
|
|
".netrc",
|
|
".git-credentials",
|
|
".pgpass",
|
|
".config/gh",
|
|
}
|
|
|
|
// MandatoryDenyOptions configures GetMandatoryDenyPatterns. AllowRead and
|
|
// AllowWrite must be already expanded (post-ExpandVariables); the function
|
|
// does not call ExpandVariables itself.
|
|
type MandatoryDenyOptions struct {
|
|
AllowGitConfig bool
|
|
AllowRead []string
|
|
AllowWrite []string
|
|
}
|
|
|
|
// MandatoryDenyResult splits mandatory denies by direction and reports the
|
|
// patterns the user opted out of (for audit logging by translators).
|
|
type MandatoryDenyResult struct {
|
|
DenyRead []string
|
|
DenyWrite []string
|
|
SuppressedRead []string
|
|
SuppressedWrite []string
|
|
}
|
|
|
|
// GetMandatoryDenyPatterns returns mandatory deny patterns for both directions,
|
|
// suppressing any pattern the user has explicitly named in the corresponding
|
|
// allow list. Suppression is exact post-expansion byte-equal match — broad
|
|
// globs in user allow lists do not suppress.
|
|
//
|
|
// .git/hooks is never suppressed (arbitrary code execution risk).
|
|
// .git/config is emitted only when !AllowGitConfig and may be suppressed.
|
|
func GetMandatoryDenyPatterns(opts MandatoryDenyOptions) MandatoryDenyResult {
|
|
allowReadSet := toSet(opts.AllowRead)
|
|
allowWriteSet := toSet(opts.AllowWrite)
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
cwd = "."
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
home = ""
|
|
}
|
|
|
|
// Naming an absolute form (CWD or HOME) of a dangerous file also suppresses
|
|
// the corresponding "**/<file>" glob on the same direction — otherwise the
|
|
// glob deny would still block the user's explicit opt-out. The unnamed
|
|
// absolute form remains mandatory.
|
|
absToDangerous := make(map[string]string)
|
|
for _, fileName := range DANGEROUS_FILES {
|
|
absToDangerous[filepath.Clean(filepath.Join(cwd, fileName))] = fileName
|
|
if home != "" {
|
|
absToDangerous[filepath.Clean(filepath.Join(home, fileName))] = fileName
|
|
}
|
|
}
|
|
|
|
readGlobAlsoSuppressed := make(map[string]bool)
|
|
for entry := range allowReadSet {
|
|
if fileName, ok := absToDangerous[entry]; ok {
|
|
readGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true
|
|
}
|
|
}
|
|
writeGlobAlsoSuppressed := make(map[string]bool)
|
|
for entry := range allowWriteSet {
|
|
if fileName, ok := absToDangerous[entry]; ok {
|
|
writeGlobAlsoSuppressed[filepath.Clean(filepath.Join("**", fileName))] = true
|
|
}
|
|
}
|
|
|
|
suppressible := []string{}
|
|
|
|
for _, fileName := range DANGEROUS_FILES {
|
|
suppressible = append(suppressible, filepath.Join(cwd, fileName))
|
|
suppressible = append(suppressible, filepath.Join("**", fileName))
|
|
if home != "" {
|
|
suppressible = append(suppressible, filepath.Join(home, fileName))
|
|
}
|
|
}
|
|
|
|
if !opts.AllowGitConfig {
|
|
suppressible = append(suppressible, filepath.Join(cwd, ".git/config"))
|
|
if home != "" {
|
|
suppressible = append(suppressible, filepath.Join(home, ".git/config"))
|
|
}
|
|
}
|
|
|
|
result := MandatoryDenyResult{}
|
|
|
|
for _, pattern := range suppressible {
|
|
cleaned := filepath.Clean(pattern)
|
|
|
|
if allowReadSet[cleaned] || readGlobAlsoSuppressed[cleaned] {
|
|
result.SuppressedRead = append(result.SuppressedRead, cleaned)
|
|
} else {
|
|
result.DenyRead = append(result.DenyRead, cleaned)
|
|
}
|
|
|
|
if allowWriteSet[cleaned] || writeGlobAlsoSuppressed[cleaned] {
|
|
result.SuppressedWrite = append(result.SuppressedWrite, cleaned)
|
|
} else {
|
|
result.DenyWrite = append(result.DenyWrite, cleaned)
|
|
}
|
|
}
|
|
|
|
// Git hooks can execute arbitrary code; never suppressible.
|
|
gitHooks := []string{
|
|
filepath.Join(cwd, ".git/hooks"),
|
|
filepath.Join(cwd, ".git/hooks/**"),
|
|
}
|
|
if home != "" {
|
|
gitHooks = append(gitHooks,
|
|
filepath.Join(home, ".git/hooks"),
|
|
filepath.Join(home, ".git/hooks/**"),
|
|
)
|
|
}
|
|
for _, p := range gitHooks {
|
|
cleaned := filepath.Clean(p)
|
|
result.DenyRead = append(result.DenyRead, cleaned)
|
|
result.DenyWrite = append(result.DenyWrite, cleaned)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func toSet(s []string) map[string]bool {
|
|
m := make(map[string]bool, len(s))
|
|
for _, v := range s {
|
|
m[filepath.Clean(v)] = true
|
|
}
|
|
return m
|
|
}
|