From d993d57e3d64a03d542b8f64b2b50137abeeb784 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Tue, 12 May 2026 18:11:18 +0530 Subject: [PATCH] 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 --- internal/flows/proxy_flow.go | 26 +- internal/runner/execute.go | 12 +- sandbox/executor/diagnostics.go | 246 +++++++++++++++ sandbox/executor/diagnostics_test.go | 113 +++++++ sandbox/platform/seatbelt_darwin.go | 10 +- .../platform/seatbelt_diagnostics_darwin.go | 283 ++++++++++++++++++ .../seatbelt_diagnostics_darwin_test.go | 78 +++++ .../platform/seatbelt_translator_darwin.go | 37 ++- .../seatbelt_translator_darwin_test.go | 31 +- sandbox/sandbox.go | 53 ++++ usefulerror/codes.go | 1 + 11 files changed, 830 insertions(+), 60 deletions(-) create mode 100644 sandbox/executor/diagnostics.go create mode 100644 sandbox/executor/diagnostics_test.go create mode 100644 sandbox/platform/seatbelt_diagnostics_darwin.go create mode 100644 sandbox/platform/seatbelt_diagnostics_darwin_test.go diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index e1e9ff0..8c89b1c 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -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) } - diff --git a/internal/runner/execute.go b/internal/runner/execute.go index 06a5b03..9e5fa15 100644 --- a/internal/runner/execute.go +++ b/internal/runner/execute.go @@ -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) } } diff --git a/sandbox/executor/diagnostics.go b/sandbox/executor/diagnostics.go new file mode 100644 index 0000000..480be52 --- /dev/null +++ b/sandbox/executor/diagnostics.go @@ -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 + } +} diff --git a/sandbox/executor/diagnostics_test.go b/sandbox/executor/diagnostics_test.go new file mode 100644 index 0000000..5721853 --- /dev/null +++ b/sandbox/executor/diagnostics_test.go @@ -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") +} diff --git a/sandbox/platform/seatbelt_darwin.go b/sandbox/platform/seatbelt_darwin.go index 6be1ed3..30304c0 100644 --- a/sandbox/platform/seatbelt_darwin.go +++ b/sandbox/platform/seatbelt_darwin.go @@ -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 { diff --git a/sandbox/platform/seatbelt_diagnostics_darwin.go b/sandbox/platform/seatbelt_diagnostics_darwin.go new file mode 100644 index 0000000..f354d3c --- /dev/null +++ b/sandbox/platform/seatbelt_diagnostics_darwin.go @@ -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 +} diff --git a/sandbox/platform/seatbelt_diagnostics_darwin_test.go b/sandbox/platform/seatbelt_diagnostics_darwin_test.go new file mode 100644 index 0000000..a4f6e72 --- /dev/null +++ b/sandbox/platform/seatbelt_diagnostics_darwin_test.go @@ -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) +} diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index a8cfae6..12f0879 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -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))) } } diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index 05c2c13..3b26260 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -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()) diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go index c22f171..7bf1317 100644 --- a/sandbox/sandbox.go +++ b/sandbox/sandbox.go @@ -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 { diff --git a/usefulerror/codes.go b/usefulerror/codes.go index 36e5f20..0eec6f6 100644 --- a/usefulerror/codes.go +++ b/usefulerror/codes.go @@ -13,5 +13,6 @@ const ( ErrCodeUnknown = "Unknown" ErrCodeLifecycle = "Lifecycle" ErrCodeNetwork = "Network" + ErrCodeSandboxViolation = "SandboxViolation" ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed" )