mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat/sandbox allow explicit dangerous pattern override (#239)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b56a8e2a43
commit
d6755d3f44
+116
-45
@@ -5,77 +5,148 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DANGEROUS_FILES are files that should always be blocked from write access
|
||||
// to prevent credential theft and security compromise.
|
||||
// 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",
|
||||
}
|
||||
|
||||
// GetMandatoryDenyPatterns returns filesystem paths that should always be blocked
|
||||
// from write access for security reasons. These are automatically injected into
|
||||
// all sandbox policies regardless of user configuration.
|
||||
// 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.
|
||||
//
|
||||
// Parameters:
|
||||
// - allowGitConfig: if false, blocks write access to .git/config (recommended)
|
||||
//
|
||||
// Returns patterns in both absolute (from HOME) and glob forms for comprehensive coverage.
|
||||
func GetMandatoryDenyPatterns(allowGitConfig bool) []string {
|
||||
patterns := []string{}
|
||||
// .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)
|
||||
|
||||
// Get current working directory for CWD-relative patterns
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
// Fallback to basic patterns if we can't get CWD
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
// Get home directory for HOME-relative patterns
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// If we can't get home, skip home-based patterns
|
||||
home = ""
|
||||
}
|
||||
|
||||
// Add dangerous files from CWD
|
||||
// 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 {
|
||||
// Absolute path in CWD
|
||||
patterns = append(patterns, filepath.Join(cwd, fileName))
|
||||
// Glob pattern to catch in subdirectories
|
||||
patterns = append(patterns, filepath.Join("**", fileName))
|
||||
}
|
||||
|
||||
// Add dangerous files from HOME (if available)
|
||||
if home != "" {
|
||||
for _, fileName := range DANGEROUS_FILES {
|
||||
patterns = append(patterns, filepath.Join(home, fileName))
|
||||
}
|
||||
}
|
||||
|
||||
// Git hooks are blocked in CWD and HOME for security (can execute arbitrary code)
|
||||
// We don't use global globs like **/.git/hooks to allow legitimate temp dir operations
|
||||
// (e.g., npx cloning repos to /tmp)
|
||||
patterns = append(patterns, filepath.Join(cwd, ".git/hooks"))
|
||||
patterns = append(patterns, filepath.Join(cwd, ".git/hooks/**"))
|
||||
|
||||
if home != "" {
|
||||
patterns = append(patterns, filepath.Join(home, ".git/hooks"))
|
||||
patterns = append(patterns, filepath.Join(home, ".git/hooks/**"))
|
||||
}
|
||||
|
||||
// Git config is conditionally blocked in CWD and HOME
|
||||
if !allowGitConfig {
|
||||
patterns = append(patterns, filepath.Join(cwd, ".git/config"))
|
||||
absToDangerous[filepath.Clean(filepath.Join(cwd, fileName))] = fileName
|
||||
if home != "" {
|
||||
patterns = append(patterns, filepath.Join(home, ".git/config"))
|
||||
absToDangerous[filepath.Clean(filepath.Join(home, fileName))] = fileName
|
||||
}
|
||||
}
|
||||
|
||||
return patterns
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user