mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,5 +13,6 @@ const (
|
||||
ErrCodeUnknown = "Unknown"
|
||||
ErrCodeLifecycle = "Lifecycle"
|
||||
ErrCodeNetwork = "Network"
|
||||
ErrCodeSandboxViolation = "SandboxViolation"
|
||||
ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user