feat: Add support for sandbox diagnostic log (#245)

* feat: Add support for sandbox diagnostic log

* fix: Normalize and prioritise sandbox violations

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-05-12 18:11:18 +05:30
committed by GitHub
parent 00fd6d2a9c
commit d993d57e3d
11 changed files with 830 additions and 60 deletions
+7 -19
View File
@@ -22,6 +22,7 @@ import (
"github.com/safedep/pmg/proxy"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/sandbox/executor"
"github.com/safedep/pmg/usefulerror"
)
@@ -394,7 +395,7 @@ func (f *proxyFlow) executeWithProxyForNonInteractiveTTY(
err = cmd.Run()
if err != nil {
return f.handlePackageManagerExecutionError(err)
return f.handlePackageManagerExecutionError(err, result)
}
}
@@ -547,33 +548,20 @@ func (f *proxyFlow) executeWithProxy(
}
if sessionError != nil {
return f.handlePackageManagerExecutionError(sessionError)
return f.handlePackageManagerExecutionError(sessionError, result)
}
return nil
}
func (f *proxyFlow) handlePackageManagerExecutionError(err error) error {
func (f *proxyFlow) handlePackageManagerExecutionError(err error, result *sandbox.ExecutionResult) error {
if exitErr, ok := err.(*exec.ExitError); ok {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())).
WithHelp("Check the package manager command and its arguments").
Wrap(err)
return executor.WrapCommandExecutionError(err, result, exitErr.ExitCode())
}
if sessionError, ok := err.(*pty.ExitError); ok {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", sessionError.Code)).
WithHelp("Check the package manager command and its arguments").
Wrap(sessionError.Err)
return executor.WrapCommandExecutionError(sessionError, result, sessionError.Code)
}
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError("Failed to execute package manager command").
WithHelp("Check the package manager command and its arguments").
Wrap(err)
return executor.WrapCommandExecutionError(err, result, -1)
}
+3 -9
View File
@@ -9,7 +9,6 @@ import (
"github.com/safedep/dry/log"
"github.com/safedep/pmg/packagemanager"
"github.com/safedep/pmg/sandbox/executor"
"github.com/safedep/pmg/usefulerror"
)
// Execute runs a package manager command without proxy or guard analysis.
@@ -42,16 +41,11 @@ func Execute(ctx context.Context, pc *packagemanager.ParsedCommand, pmName strin
if result.ShouldRun() {
if err := cmd.Run(); err != nil {
humanError := "Failed to execute package manager command"
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())
exitCode = exitErr.ExitCode()
}
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError(humanError).
WithHelp("Check the package manager command and its arguments").
Wrap(err)
return executor.WrapCommandExecutionError(err, result, exitCode)
}
}
+246
View File
@@ -0,0 +1,246 @@
package executor
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/sandbox"
"github.com/safedep/pmg/usefulerror"
)
// WrapCommandExecutionError converts a package manager execution error into a
// user-facing error. When sandbox diagnostics are available, they take
// precedence over the generic exit-code-only message.
func WrapCommandExecutionError(err error, result *sandbox.ExecutionResult, exitCode int) error {
if err == nil {
return nil
}
if result != nil {
report, diagErr := result.BestEffortViolation(err)
if diagErr != nil {
log.Warnf("failed to collect sandbox diagnostics: %v", diagErr)
} else if report != nil && len(report.Violations) > 0 {
return usefulerror.Useful().
WithCode(usefulerror.ErrCodeSandboxViolation).
WithHumanError("PMG sandbox blocked this command").
WithHelp(buildSandboxHint(report)).
WithAdditionalHelp(buildSandboxDetails(report)).
Wrap(err)
}
}
humanError := "Failed to execute package manager command"
if exitCode >= 0 {
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitCode)
}
return usefulerror.Useful().
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
WithHumanError(humanError).
WithHelp("Check the package manager command and its arguments").
Wrap(err)
}
func buildSandboxHint(report *sandbox.ViolationReport) string {
first := primarySandboxViolation(report)
if first == nil {
return "Reason: sandbox denied an operation"
}
hint := fmt.Sprintf("Reason: %s", first.RuleLabel)
if override := suggestSandboxOverride(*first); override != "" {
hint = fmt.Sprintf("%s. Override: %s", hint, override)
}
return hint
}
func buildSandboxDetails(report *sandbox.ViolationReport) string {
first := primarySandboxViolation(report)
if first == nil {
return ""
}
lines := []string{
fmt.Sprintf("Sandbox: %s", report.SandboxName),
fmt.Sprintf("Policy: %s", report.PolicyName),
fmt.Sprintf("Correlation: %s", report.CorrelationID),
fmt.Sprintf("Process: %s", emptyFallback(first.Process, "unknown")),
fmt.Sprintf("Violation: %s", first.RuleLabel),
}
if first.RuleTarget != "" && first.RuleTarget != first.Target {
lines = append(lines, fmt.Sprintf("Matched rule: %s", first.RuleTarget))
}
if first.RawLog != "" {
lines = append(lines, fmt.Sprintf("Seatbelt log: %s", first.RawLog))
}
if len(report.Violations) > 1 {
lines = append(lines, fmt.Sprintf("Additional denials observed: %d", len(report.Violations)-1))
}
return strings.Join(lines, "\n")
}
func suggestSandboxOverride(v sandbox.Violation) string {
if !isSafeSandboxOverrideTarget(v.Target) {
return ""
}
quotedTarget := shellQuote(v.Target)
switch v.Kind {
case sandbox.ViolationKindFSRead:
return fmt.Sprintf("--sandbox-allow read=%s", quotedTarget)
case sandbox.ViolationKindFSWrite, sandbox.ViolationKindFSDeleteOrRename:
return fmt.Sprintf("--sandbox-allow write=%s", quotedTarget)
case sandbox.ViolationKindExec:
return fmt.Sprintf("--sandbox-allow exec=%s", quotedTarget)
default:
return ""
}
}
func isSafeSandboxOverrideTarget(value string) bool {
if value == "" {
return false
}
if strings.ContainsAny(value, "*?[]") {
return false
}
for _, r := range value {
if r == 0 || r < 0x20 || r == 0x7f {
return false
}
}
return true
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
func emptyFallback(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func primarySandboxViolation(report *sandbox.ViolationReport) *sandbox.Violation {
if report == nil || len(report.Violations) == 0 {
return nil
}
cwd, _ := os.Getwd()
bestIdx := 0
bestScore := scoreSandboxViolation(report.Violations[0], cwd)
for i := 1; i < len(report.Violations); i++ {
score := scoreSandboxViolation(report.Violations[i], cwd)
if score > bestScore || (score == bestScore && i > bestIdx) {
bestIdx = i
bestScore = score
}
}
return &report.Violations[bestIdx]
}
func scoreSandboxViolation(v sandbox.Violation, cwd string) int {
score := 0
switch v.Kind {
case sandbox.ViolationKindFSRead, sandbox.ViolationKindFSWrite:
score += 120
case sandbox.ViolationKindExec:
score += 110
case sandbox.ViolationKindFSDeleteOrRename:
score += 100
case sandbox.ViolationKindGenericDeny:
score += 10
default:
score += 30
}
if isSafeSandboxOverrideTarget(v.Target) {
score += 40
}
if v.Target != "" && v.Target != v.RuleTarget {
score += 20
}
if isProjectPath(v.Target, cwd) {
score += 80
}
if isSensitiveProjectFile(v.Target) {
score += 60
}
if isNoisySystemPath(v.Target) {
score -= 120
}
if v.Kind == sandbox.ViolationKindGenericDeny && v.Target == "" {
score -= 40
}
return score
}
func isProjectPath(target, cwd string) bool {
if target == "" || cwd == "" {
return false
}
if strings.HasPrefix(target, ".") {
return true
}
cleanTarget := filepath.Clean(target)
cleanCwd := filepath.Clean(cwd)
return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator))
}
func isSensitiveProjectFile(target string) bool {
if target == "" {
return false
}
base := filepath.Base(target)
switch {
case strings.HasPrefix(base, ".env"):
return true
case base == ".npmrc", base == ".pypirc", base == ".netrc":
return true
case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg":
return true
default:
return strings.Contains(target, string(filepath.Separator)+".ssh") ||
strings.Contains(target, string(filepath.Separator)+".aws") ||
strings.Contains(target, string(filepath.Separator)+".kube")
}
}
func isNoisySystemPath(target string) bool {
switch target {
case "/dev/dtracehelper":
return true
default:
return false
}
}
+113
View File
@@ -0,0 +1,113 @@
package executor
import (
"os"
"path/filepath"
"testing"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
)
func TestSuggestSandboxOverrideSkipsGlobRuleTarget(t *testing.T) {
assert.Empty(t, suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "**/.env",
}))
}
func TestSuggestSandboxOverrideUsesConcretePath(t *testing.T) {
assert.Equal(t, "--sandbox-allow read='./.env'", suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "./.env",
}))
}
func TestSuggestSandboxOverrideQuotesSpacesAndSingleQuotes(t *testing.T) {
assert.Equal(t, "--sandbox-allow read='/tmp/My Dir/it'\\''s.env'", suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "/tmp/My Dir/it's.env",
}))
}
func TestSuggestSandboxOverrideSkipsControlCharacters(t *testing.T) {
assert.Empty(t, suggestSandboxOverride(sandbox.Violation{
Kind: sandbox.ViolationKindFSRead,
Target: "/tmp/bad\npath",
}))
}
func TestBuildSandboxDetailsIncludesMatchedRule(t *testing.T) {
details := buildSandboxDetails(&sandbox.ViolationReport{
SandboxName: "seatbelt",
PolicyName: "npm-restrictive",
CorrelationID: "run-1",
Violations: []sandbox.Violation{
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "file-read",
Target: "./.env",
RuleTarget: "**/.env",
Process: "node",
RuleLabel: "read access denied: ./.env",
},
},
})
assert.Contains(t, details, "Matched rule: **/.env")
}
func TestPrimarySandboxViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
report := &sandbox.ViolationReport{
Violations: []sandbox.Violation{
{
Kind: sandbox.ViolationKindGenericDeny,
RawKind: "default",
Target: "/dev/dtracehelper",
RuleLabel: "sandbox denied access to /dev/dtracehelper",
},
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "file-read",
Target: filepath.Join(cwd, ".env"),
RuleTarget: "**/.env",
RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"),
},
},
}
primary := primarySandboxViolation(report)
if assert.NotNil(t, primary) {
assert.Equal(t, sandbox.ViolationKindFSRead, primary.Kind)
assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target)
}
}
func TestBuildSandboxHintUsesRankedPrimaryViolation(t *testing.T) {
cwd, err := os.Getwd()
assert.NoError(t, err)
hint := buildSandboxHint(&sandbox.ViolationReport{
Violations: []sandbox.Violation{
{
Kind: sandbox.ViolationKindGenericDeny,
RawKind: "default",
Target: "/dev/dtracehelper",
RuleLabel: "sandbox denied access to /dev/dtracehelper",
},
{
Kind: sandbox.ViolationKindFSRead,
RawKind: "file-read",
Target: filepath.Join(cwd, ".env"),
RuleTarget: "**/.env",
RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"),
},
},
})
assert.Contains(t, hint, "Reason: read access denied:")
assert.NotContains(t, hint, "/dev/dtracehelper")
}
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"os/exec"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/sandbox"
@@ -17,6 +18,9 @@ type seatbeltSandbox struct {
translator *seatbeltPolicyTranslator
tempProfilePath string
cleanupCompleted bool
policyName string
logTag string
startedAt time.Time
}
func newSeatbeltSandbox() (*seatbeltSandbox, error) {
@@ -38,7 +42,11 @@ func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa
}
// Log pattern referred in sandbox.md as debugging guidance
log.Debugf("MacOS Seatbelt sandbox log tag: %s", s.translator.LogTag())
s.logTag = s.translator.LogTag()
s.policyName = policy.Name
s.startedAt = time.Now()
log.Debugf("MacOS Seatbelt sandbox log tag: %s", s.logTag)
tmpFile, err := os.CreateTemp("", "pmg-sandbox-*.sb")
if err != nil {
@@ -0,0 +1,283 @@
//go:build darwin
// +build darwin
package platform
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"regexp"
"strings"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/sandbox"
)
const seatbeltLogWindowPadding = 2 * time.Second
const seatbeltMaxQueryWait = 6 * time.Second
const seatbeltQueryInterval = 250 * time.Millisecond
const seatbeltLogCommandTimeout = 5 * time.Second
const macOSUnifiedLogPath = "/usr/bin/log"
var seatbeltMessagePattern = regexp.MustCompile(`PMG_SBX\|run=([^|]+)\|kind=([^|]+)\|target=([^"\s]*)`)
type seatbeltLogEntry struct {
EventMessage string `json:"eventMessage"`
Process string `json:"process"`
ProcessImagePath string `json:"processImagePath"`
}
type seatbeltLogPayload struct {
RunID string
Kind string
Target string
}
func parseSeatbeltLogPayload(raw string) (*seatbeltLogPayload, bool) {
matches := seatbeltMessagePattern.FindStringSubmatch(raw)
if len(matches) != 4 {
return nil, false
}
target, err := urlQueryUnescape(matches[3])
if err != nil {
target = matches[3]
}
return &seatbeltLogPayload{
RunID: matches[1],
Kind: matches[2],
Target: target,
}, true
}
func (s *seatbeltSandbox) BestEffortViolation(err error) (*sandbox.ViolationReport, error) {
if err == nil || s.logTag == "" || s.startedAt.IsZero() {
return nil, nil
}
deadline := time.Now().Add(seatbeltMaxQueryWait)
var lastQueryErr error
for {
end := time.Now()
entries, queryErr := s.queryLogs(s.startedAt.Add(-seatbeltLogWindowPadding), end.Add(seatbeltLogWindowPadding))
if queryErr != nil {
lastQueryErr = queryErr
} else {
violations := extractSeatbeltViolations(entries, s.logTag)
if len(violations) > 0 {
return &sandbox.ViolationReport{
SandboxName: s.Name(),
PolicyName: s.policyName,
CorrelationID: s.logTag,
Violations: violations,
}, nil
}
}
if time.Now().After(deadline) {
break
}
time.Sleep(seatbeltQueryInterval)
}
if lastQueryErr != nil {
return nil, lastQueryErr
}
return nil, nil
}
func extractSeatbeltViolations(entries []seatbeltLogEntry, runID string) []sandbox.Violation {
violations := make([]sandbox.Violation, 0, len(entries))
for _, entry := range entries {
payload, ok := parseSeatbeltLogPayload(entry.EventMessage)
if !ok || payload.RunID != runID {
continue
}
process := entry.Process
if process == "" {
process = entry.ProcessImagePath
}
target := extractSeatbeltDeniedPath(entry.EventMessage, payload)
if target == "" {
target = payload.Target
}
violations = append(violations, sandbox.Violation{
Kind: normalizeSeatbeltViolationKind(payload.Kind),
RawKind: payload.Kind,
Target: target,
RuleTarget: payload.Target,
Process: process,
RawLog: strings.TrimSpace(entry.EventMessage),
RuleLabel: summarizeSeatbeltViolation(payload.Kind, target),
})
}
return violations
}
func normalizeSeatbeltViolationKind(kind string) sandbox.ViolationKind {
switch kind {
case "file-read":
return sandbox.ViolationKindFSRead
case "file-write":
return sandbox.ViolationKindFSWrite
case "file-write-unlink":
return sandbox.ViolationKindFSDeleteOrRename
case "process-exec":
return sandbox.ViolationKindExec
default:
return sandbox.ViolationKindGenericDeny
}
}
func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string {
if payload == nil {
return ""
}
marker := seatbeltLogMessage(payload.RunID, payload.Kind, payload.Target)
idx := strings.Index(raw, marker)
if idx < 0 {
return ""
}
prefix := strings.TrimSpace(raw[:idx])
if prefix == "" {
return ""
}
fields := strings.Fields(prefix)
if len(fields) == 0 {
return ""
}
last := strings.TrimSpace(fields[len(fields)-1])
last = strings.Trim(last, "\"',;:()[]{}")
if last == "" || !looksLikeConcretePath(last) {
return ""
}
return last
}
func (s *seatbeltSandbox) queryLogs(start, end time.Time) ([]seatbeltLogEntry, error) {
info, err := os.Stat(macOSUnifiedLogPath)
if err != nil {
return nil, fmt.Errorf("macOS unified log CLI not available: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("macOS unified log CLI not available: %s is a directory", macOSUnifiedLogPath)
}
predicate := fmt.Sprintf(`eventMessage CONTAINS "PMG_SBX|run=%s|"`, s.logTag)
args := []string{
"show",
"--style", "json",
"--start", start.Format("2006-01-02 15:04:05"),
"--end", end.Format("2006-01-02 15:04:05"),
"--predicate", predicate,
}
ctx, cancel := context.WithTimeout(context.Background(), seatbeltLogCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, macOSUnifiedLogPath, args...)
output, err := cmd.Output()
if err != nil {
var stderr bytes.Buffer
if ee, ok := err.(*exec.ExitError); ok {
stderr.Write(ee.Stderr)
}
return nil, fmt.Errorf("query seatbelt logs: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
entries, err := decodeSeatbeltLogEntries(output)
if err != nil {
return nil, fmt.Errorf("decode seatbelt logs: %w", err)
}
return entries, nil
}
func decodeSeatbeltLogEntries(data []byte) ([]seatbeltLogEntry, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, nil
}
var list []seatbeltLogEntry
if err := json.Unmarshal(trimmed, &list); err == nil {
return list, nil
}
decoder := json.NewDecoder(bytes.NewReader(trimmed))
entries := []seatbeltLogEntry{}
for {
var entry seatbeltLogEntry
if err := decoder.Decode(&entry); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
entries = append(entries, entry)
}
return entries, nil
}
func summarizeSeatbeltViolation(kind, target string) string {
switch kind {
case "file-read":
return fmt.Sprintf("read access denied: %s", target)
case "file-write":
return fmt.Sprintf("write access denied: %s", target)
case "file-write-unlink":
return fmt.Sprintf("rename or unlink denied: %s", target)
case "process-exec":
return fmt.Sprintf("process execution denied: %s", target)
default:
if target == "" {
return "sandbox denied an operation"
}
return fmt.Sprintf("sandbox denied access to %s", target)
}
}
func looksLikeConcretePath(value string) bool {
if value == "" {
return false
}
if strings.ContainsAny(value, "*?[]") {
return false
}
return strings.Contains(value, "/") || strings.HasPrefix(value, ".")
}
func urlQueryUnescape(value string) (string, error) {
unescaped, err := url.QueryUnescape(value)
if err != nil {
log.Debugf("seatbelt diagnostics: failed to decode %q: %v", value, err)
return "", err
}
return unescaped, nil
}
@@ -0,0 +1,78 @@
//go:build darwin
// +build darwin
package platform
import (
"testing"
"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSeatbeltLogPayload(t *testing.T) {
msg := `Sandbox: node(123) deny(1) file-write-data /Users/dev/project/.env ` +
seatbeltLogMessage("run-1", "file-write", "/Users/dev/project/.env")
payload, ok := parseSeatbeltLogPayload(msg)
require.True(t, ok)
assert.Equal(t, "run-1", payload.RunID)
assert.Equal(t, "file-write", payload.Kind)
assert.Equal(t, "/Users/dev/project/.env", payload.Target)
}
func TestParseSeatbeltLogPayloadEmptyTarget(t *testing.T) {
msg := `Sandbox: node(123) deny(1) default ` + seatbeltLogMessage("run-1", "default", "")
payload, ok := parseSeatbeltLogPayload(msg)
require.True(t, ok)
assert.Equal(t, "run-1", payload.RunID)
assert.Equal(t, "default", payload.Kind)
assert.Empty(t, payload.Target)
}
func TestExtractSeatbeltDeniedPath(t *testing.T) {
payload := &seatbeltLogPayload{
RunID: "run-1",
Kind: "file-read",
Target: "**/.env",
}
raw := `Sandbox: node(123) deny(1) file-read-data ./.env ` + seatbeltLogMessage("run-1", "file-read", "**/.env")
assert.Equal(t, "./.env", extractSeatbeltDeniedPath(raw, payload))
}
func TestDecodeSeatbeltLogEntries(t *testing.T) {
data := []byte(`[
{"eventMessage":"entry-1","process":"node"},
{"eventMessage":"entry-2","process":"npm"}
]`)
entries, err := decodeSeatbeltLogEntries(data)
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "entry-1", entries[0].EventMessage)
assert.Equal(t, "npm", entries[1].Process)
}
func TestExtractSeatbeltViolations(t *testing.T) {
entries := []seatbeltLogEntry{
{
EventMessage: "Sandbox deny " + seatbeltLogMessage("run-1", "file-read", "/tmp/.env"),
Process: "node",
},
{
EventMessage: "Sandbox deny " + seatbeltLogMessage("run-2", "file-write", "/tmp/out"),
Process: "npm",
},
}
violations := extractSeatbeltViolations(entries, "run-1")
require.Len(t, violations, 1)
assert.Equal(t, sandbox.ViolationKindFSRead, violations[0].Kind)
assert.Equal(t, "file-read", violations[0].RawKind)
assert.Equal(t, "/tmp/.env", violations[0].Target)
assert.Equal(t, "/tmp/.env", violations[0].RuleTarget)
assert.Equal(t, "node", violations[0].Process)
}
+21 -16
View File
@@ -6,6 +6,7 @@ package platform
import (
"crypto/rand"
"fmt"
"net/url"
"path/filepath"
"strings"
@@ -28,6 +29,10 @@ func generateLogTag() string {
return fmt.Sprintf("PMG_SBX_%s", randomStr[:12])
}
func seatbeltLogMessage(runID, kind, target string) string {
return fmt.Sprintf("PMG_SBX|run=%s|kind=%s|target=%s", runID, kind, url.QueryEscape(target))
}
func newSeatbeltPolicyTranslator() *seatbeltPolicyTranslator {
return &seatbeltPolicyTranslator{
logTag: generateLogTag(),
@@ -140,23 +145,23 @@ func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
if util.ContainsGlob(pathPattern) {
// For glob patterns, use regex matching for precise pattern enforcement
regexPattern := util.GlobToRegex(pathPattern)
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (regex #\"%s\") (with message \"%s\"))", regexPattern, logTag))
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (regex #\"%s\") (with message \"%s\"))", regexPattern, seatbeltLogMessage(logTag, "file-write-unlink", pathPattern)))
// Also block moving the base directory to prevent bypass
baseDir := extractBaseDir(pathPattern)
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", baseDir, logTag))
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", baseDir, seatbeltLogMessage(logTag, "file-write-unlink", baseDir)))
// Block moving ancestor directories
for _, ancestorDir := range getAncestorDirectories(baseDir) {
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, logTag))
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, seatbeltLogMessage(logTag, "file-write-unlink", ancestorDir)))
}
} else {
// For literal paths, use subpath matching
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", pathPattern, logTag))
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (subpath \"%s\") (with message \"%s\"))", pathPattern, seatbeltLogMessage(logTag, "file-write-unlink", pathPattern)))
// Block moving ancestor directories
for _, ancestorDir := range getAncestorDirectories(pathPattern) {
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, logTag))
rules = append(rules, fmt.Sprintf("(deny file-write-unlink (literal \"%s\") (with message \"%s\"))", ancestorDir, seatbeltLogMessage(logTag, "file-write-unlink", ancestorDir)))
}
}
}
@@ -179,7 +184,7 @@ func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (str
// Default policy: deny by default for maximum security
// Add log tag to track what gets denied by the default rule
sb.WriteString(fmt.Sprintf("(deny default (with message \"%s\"))\n\n", t.logTag))
sb.WriteString(fmt.Sprintf("(deny default (with message \"%s\"))\n\n", seatbeltLogMessage(t.logTag, "default", "")))
// Essential system permissions - based on Chrome/Chromium sandbox policy
// These are the minimum permissions needed for stable process execution
@@ -447,9 +452,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)))
}
expandedDenyRead = append(expandedDenyRead, expanded)
}
@@ -474,9 +479,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
// Use regex matching for glob patterns, subpath for literals
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
@@ -517,9 +522,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)))
} else {
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)))
}
expandedDenyWrite = append(expandedDenyWrite, expanded)
}
@@ -532,9 +537,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
if util.ContainsGlob(expanded) {
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)))
} else {
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)))
}
}
@@ -640,9 +645,9 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic
if util.ContainsGlob(expanded) {
// For glob patterns, use regex matching for precise control
regexPattern := util.GlobToRegex(expanded)
sb.WriteString(fmt.Sprintf("(deny process-exec* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, t.logTag))
sb.WriteString(fmt.Sprintf("(deny process-exec* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "process-exec", expanded)))
} else {
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\") (with message \"%s\"))\n", expanded, t.logTag))
sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "process-exec", expanded)))
}
}
@@ -446,9 +446,9 @@ func TestGenerateMoveBlockingRules(t *testing.T) {
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should block moving the path itself
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/sensitive/data\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (subpath \"/sensitive/data\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/sensitive/data")))
// Should block moving the parent directory
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/sensitive\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/sensitive\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/sensitive")))
},
},
{
@@ -457,9 +457,9 @@ func TestGenerateMoveBlockingRules(t *testing.T) {
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should block moving the base directory
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/path/to\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (subpath \"/path/to\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/path/to")))
// Should block moving ancestor directories
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/path\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/path\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/path")))
},
},
{
@@ -467,11 +467,11 @@ func TestGenerateMoveBlockingRules(t *testing.T) {
patterns: []string{"/tmp/test", "/var/log/app"},
logTag: "test",
assert: func(t *testing.T, rules []string) {
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/tmp/test\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/tmp\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/var/log/app\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/var/log\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/var\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (subpath \"/tmp/test\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/tmp/test")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/tmp\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/tmp")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (subpath \"/var/log/app\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/var/log/app")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/var/log\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/var/log")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/var\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/var")))
},
},
{
@@ -480,11 +480,11 @@ func TestGenerateMoveBlockingRules(t *testing.T) {
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should have rules for all ancestors
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c/d/e\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c/d\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b/c\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a/b\") (with message \"test\"))")
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/a\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/a/b/c/d/e\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/a/b/c/d/e")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/a/b/c/d\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/a/b/c/d")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/a/b/c\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/a/b/c")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/a/b\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/a/b")))
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (literal \"/a\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/a")))
},
},
{
@@ -493,7 +493,7 @@ func TestGenerateMoveBlockingRules(t *testing.T) {
logTag: "test",
assert: func(t *testing.T, rules []string) {
// Should only have the file itself, no ancestors
assert.Contains(t, rules, "(deny file-write-unlink (subpath \"/file\") (with message \"test\"))")
assert.Contains(t, rules, fmt.Sprintf("(deny file-write-unlink (subpath \"/file\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/file")))
// Should not contain root as ancestor
for _, rule := range rules {
assert.NotContains(t, rule, "(deny file-write-unlink (literal \"/\"))")
@@ -666,6 +666,7 @@ func TestNetworkBindSupport(t *testing.T) {
func TestSeatbeltTranslatorDarwinLogTag(t *testing.T) {
translator := newSeatbeltPolicyTranslator()
assert.NotEmpty(t, translator.LogTag())
assert.Contains(t, seatbeltLogMessage(translator.LogTag(), "file-read", "/tmp/test"), "run="+translator.LogTag())
translator = &seatbeltPolicyTranslator{logTag: "test"}
assert.Equal(t, "test", translator.LogTag())
+53
View File
@@ -5,6 +5,43 @@ import (
"os/exec"
)
// ViolationKind is PMG's normalized taxonomy for sandbox denials.
type ViolationKind string
const (
ViolationKindFSRead ViolationKind = "fs_read"
ViolationKindFSWrite ViolationKind = "fs_write"
ViolationKindFSDeleteOrRename ViolationKind = "fs_delete_or_rename"
ViolationKindExec ViolationKind = "exec"
ViolationKindNetworkConnect ViolationKind = "network_connect"
ViolationKindNetworkBind ViolationKind = "network_bind"
ViolationKindGenericDeny ViolationKind = "generic_deny"
)
// ViolationReport is a best-effort sandbox violation summary collected from a
// sandbox implementation after command execution fails.
type ViolationReport struct {
SandboxName string
PolicyName string
CorrelationID string
Violations []Violation
}
// Violation captures one sandbox denial event.
type Violation struct {
Kind ViolationKind
RawKind string
Target string
RuleTarget string
Process string
RawLog string
RuleLabel string
}
type violationReporter interface {
BestEffortViolation(err error) (*ViolationReport, error)
}
// ExecutionResult represents the result of executing a command in a sandbox.
// It contains sandbox internal state and allows for future extension with
// additional metadata (e.g., exit codes, resource usage, violation events).
@@ -46,6 +83,22 @@ func (r *ExecutionResult) ShouldRun() bool {
return !r.executed
}
// BestEffortViolation returns sandbox-specific best-effort violation details.
// Implementations may use platform logs or other weak signals, so callers
// should treat the result as advisory.
func (r *ExecutionResult) BestEffortViolation(err error) (*ViolationReport, error) {
if r == nil || r.sandbox == nil {
return nil, nil
}
reporter, ok := r.sandbox.(violationReporter)
if !ok {
return nil, nil
}
return reporter.BestEffortViolation(err)
}
// Close cleans up any resources allocated by the sandbox.
// Must be called after cmd.Run() completes.
func (r *ExecutionResult) Close() error {
+1
View File
@@ -13,5 +13,6 @@ const (
ErrCodeUnknown = "Unknown"
ErrCodeLifecycle = "Lifecycle"
ErrCodeNetwork = "Network"
ErrCodeSandboxViolation = "SandboxViolation"
ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed"
)