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"
|
||||||
"github.com/safedep/pmg/proxy/certmanager"
|
"github.com/safedep/pmg/proxy/certmanager"
|
||||||
"github.com/safedep/pmg/proxy/interceptors"
|
"github.com/safedep/pmg/proxy/interceptors"
|
||||||
|
"github.com/safedep/pmg/sandbox"
|
||||||
"github.com/safedep/pmg/sandbox/executor"
|
"github.com/safedep/pmg/sandbox/executor"
|
||||||
"github.com/safedep/pmg/usefulerror"
|
"github.com/safedep/pmg/usefulerror"
|
||||||
)
|
)
|
||||||
@@ -394,7 +395,7 @@ func (f *proxyFlow) executeWithProxyForNonInteractiveTTY(
|
|||||||
|
|
||||||
err = cmd.Run()
|
err = cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f.handlePackageManagerExecutionError(err)
|
return f.handlePackageManagerExecutionError(err, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,33 +548,20 @@ func (f *proxyFlow) executeWithProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sessionError != nil {
|
if sessionError != nil {
|
||||||
return f.handlePackageManagerExecutionError(sessionError)
|
return f.handlePackageManagerExecutionError(sessionError, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
return usefulerror.Useful().
|
return executor.WrapCommandExecutionError(err, result, exitErr.ExitCode())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionError, ok := err.(*pty.ExitError); ok {
|
if sessionError, ok := err.(*pty.ExitError); ok {
|
||||||
return usefulerror.Useful().
|
return executor.WrapCommandExecutionError(sessionError, result, sessionError.Code)
|
||||||
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 usefulerror.Useful().
|
return executor.WrapCommandExecutionError(err, result, -1)
|
||||||
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
||||||
WithHumanError("Failed to execute package manager command").
|
|
||||||
WithHelp("Check the package manager command and its arguments").
|
|
||||||
Wrap(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/packagemanager"
|
"github.com/safedep/pmg/packagemanager"
|
||||||
"github.com/safedep/pmg/sandbox/executor"
|
"github.com/safedep/pmg/sandbox/executor"
|
||||||
"github.com/safedep/pmg/usefulerror"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Execute runs a package manager command without proxy or guard analysis.
|
// 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 result.ShouldRun() {
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
humanError := "Failed to execute package manager command"
|
exitCode := -1
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
humanError = fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())
|
exitCode = exitErr.ExitCode()
|
||||||
}
|
}
|
||||||
|
return executor.WrapCommandExecutionError(err, result, exitCode)
|
||||||
return usefulerror.Useful().
|
|
||||||
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
||||||
WithHumanError(humanError).
|
|
||||||
WithHelp("Check the package manager command and its arguments").
|
|
||||||
Wrap(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/sandbox"
|
"github.com/safedep/pmg/sandbox"
|
||||||
@@ -17,6 +18,9 @@ type seatbeltSandbox struct {
|
|||||||
translator *seatbeltPolicyTranslator
|
translator *seatbeltPolicyTranslator
|
||||||
tempProfilePath string
|
tempProfilePath string
|
||||||
cleanupCompleted bool
|
cleanupCompleted bool
|
||||||
|
policyName string
|
||||||
|
logTag string
|
||||||
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSeatbeltSandbox() (*seatbeltSandbox, error) {
|
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 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")
|
tmpFile, err := os.CreateTemp("", "pmg-sandbox-*.sb")
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ func generateLogTag() string {
|
|||||||
return fmt.Sprintf("PMG_SBX_%s", randomStr[:12])
|
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 {
|
func newSeatbeltPolicyTranslator() *seatbeltPolicyTranslator {
|
||||||
return &seatbeltPolicyTranslator{
|
return &seatbeltPolicyTranslator{
|
||||||
logTag: generateLogTag(),
|
logTag: generateLogTag(),
|
||||||
@@ -140,23 +145,23 @@ func generateMoveBlockingRules(pathPatterns []string, logTag string) []string {
|
|||||||
if util.ContainsGlob(pathPattern) {
|
if util.ContainsGlob(pathPattern) {
|
||||||
// For glob patterns, use regex matching for precise pattern enforcement
|
// For glob patterns, use regex matching for precise pattern enforcement
|
||||||
regexPattern := util.GlobToRegex(pathPattern)
|
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
|
// Also block moving the base directory to prevent bypass
|
||||||
baseDir := extractBaseDir(pathPattern)
|
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
|
// Block moving ancestor directories
|
||||||
for _, ancestorDir := range getAncestorDirectories(baseDir) {
|
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 {
|
} else {
|
||||||
// For literal paths, use subpath matching
|
// 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
|
// Block moving ancestor directories
|
||||||
for _, ancestorDir := range getAncestorDirectories(pathPattern) {
|
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
|
// Default policy: deny by default for maximum security
|
||||||
// Add log tag to track what gets denied by the default rule
|
// 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
|
// Essential system permissions - based on Chrome/Chromium sandbox policy
|
||||||
// These are the minimum permissions needed for stable process execution
|
// 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
|
// Use regex matching for glob patterns, subpath for literals
|
||||||
if util.ContainsGlob(expanded) {
|
if util.ContainsGlob(expanded) {
|
||||||
regexPattern := util.GlobToRegex(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 {
|
} 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)
|
expandedDenyRead = append(expandedDenyRead, expanded)
|
||||||
}
|
}
|
||||||
@@ -474,9 +479,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
|
|||||||
// Use regex matching for glob patterns, subpath for literals
|
// Use regex matching for glob patterns, subpath for literals
|
||||||
if util.ContainsGlob(expanded) {
|
if util.ContainsGlob(expanded) {
|
||||||
regexPattern := util.GlobToRegex(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 {
|
} 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)
|
expandedDenyWrite = append(expandedDenyWrite, expanded)
|
||||||
}
|
}
|
||||||
@@ -517,9 +522,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
|
|||||||
|
|
||||||
if util.ContainsGlob(expanded) {
|
if util.ContainsGlob(expanded) {
|
||||||
regexPattern := util.GlobToRegex(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 {
|
} 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)
|
expandedDenyWrite = append(expandedDenyWrite, expanded)
|
||||||
}
|
}
|
||||||
@@ -532,9 +537,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo
|
|||||||
|
|
||||||
if util.ContainsGlob(expanded) {
|
if util.ContainsGlob(expanded) {
|
||||||
regexPattern := util.GlobToRegex(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 {
|
} 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) {
|
if util.ContainsGlob(expanded) {
|
||||||
// For glob patterns, use regex matching for precise control
|
// For glob patterns, use regex matching for precise control
|
||||||
regexPattern := util.GlobToRegex(expanded)
|
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 {
|
} 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",
|
logTag: "test",
|
||||||
assert: func(t *testing.T, rules []string) {
|
assert: func(t *testing.T, rules []string) {
|
||||||
// Should block moving the path itself
|
// 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
|
// 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",
|
logTag: "test",
|
||||||
assert: func(t *testing.T, rules []string) {
|
assert: func(t *testing.T, rules []string) {
|
||||||
// Should block moving the base directory
|
// 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
|
// 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"},
|
patterns: []string{"/tmp/test", "/var/log/app"},
|
||||||
logTag: "test",
|
logTag: "test",
|
||||||
assert: func(t *testing.T, rules []string) {
|
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, fmt.Sprintf("(deny file-write-unlink (subpath \"/tmp/test\") (with message \"%s\"))", seatbeltLogMessage("test", "file-write-unlink", "/tmp/test")))
|
||||||
assert.Contains(t, rules, "(deny file-write-unlink (literal \"/tmp\") (with message \"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, "(deny file-write-unlink (subpath \"/var/log/app\") (with message \"test\"))")
|
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, "(deny file-write-unlink (literal \"/var/log\") (with message \"test\"))")
|
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, "(deny file-write-unlink (literal \"/var\") (with message \"test\"))")
|
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",
|
logTag: "test",
|
||||||
assert: func(t *testing.T, rules []string) {
|
assert: func(t *testing.T, rules []string) {
|
||||||
// Should have rules for all ancestors
|
// 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, 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, "(deny file-write-unlink (literal \"/a/b/c/d\") (with message \"test\"))")
|
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, "(deny file-write-unlink (literal \"/a/b/c\") (with message \"test\"))")
|
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, "(deny file-write-unlink (literal \"/a/b\") (with message \"test\"))")
|
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, "(deny file-write-unlink (literal \"/a\") (with message \"test\"))")
|
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",
|
logTag: "test",
|
||||||
assert: func(t *testing.T, rules []string) {
|
assert: func(t *testing.T, rules []string) {
|
||||||
// Should only have the file itself, no ancestors
|
// 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
|
// Should not contain root as ancestor
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
assert.NotContains(t, rule, "(deny file-write-unlink (literal \"/\"))")
|
assert.NotContains(t, rule, "(deny file-write-unlink (literal \"/\"))")
|
||||||
@@ -666,6 +666,7 @@ func TestNetworkBindSupport(t *testing.T) {
|
|||||||
func TestSeatbeltTranslatorDarwinLogTag(t *testing.T) {
|
func TestSeatbeltTranslatorDarwinLogTag(t *testing.T) {
|
||||||
translator := newSeatbeltPolicyTranslator()
|
translator := newSeatbeltPolicyTranslator()
|
||||||
assert.NotEmpty(t, translator.LogTag())
|
assert.NotEmpty(t, translator.LogTag())
|
||||||
|
assert.Contains(t, seatbeltLogMessage(translator.LogTag(), "file-read", "/tmp/test"), "run="+translator.LogTag())
|
||||||
|
|
||||||
translator = &seatbeltPolicyTranslator{logTag: "test"}
|
translator = &seatbeltPolicyTranslator{logTag: "test"}
|
||||||
assert.Equal(t, "test", translator.LogTag())
|
assert.Equal(t, "test", translator.LogTag())
|
||||||
|
|||||||
@@ -5,6 +5,43 @@ import (
|
|||||||
"os/exec"
|
"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.
|
// ExecutionResult represents the result of executing a command in a sandbox.
|
||||||
// It contains sandbox internal state and allows for future extension with
|
// It contains sandbox internal state and allows for future extension with
|
||||||
// additional metadata (e.g., exit codes, resource usage, violation events).
|
// additional metadata (e.g., exit codes, resource usage, violation events).
|
||||||
@@ -46,6 +83,22 @@ func (r *ExecutionResult) ShouldRun() bool {
|
|||||||
return !r.executed
|
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.
|
// Close cleans up any resources allocated by the sandbox.
|
||||||
// Must be called after cmd.Run() completes.
|
// Must be called after cmd.Run() completes.
|
||||||
func (r *ExecutionResult) Close() error {
|
func (r *ExecutionResult) Close() error {
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ const (
|
|||||||
ErrCodeUnknown = "Unknown"
|
ErrCodeUnknown = "Unknown"
|
||||||
ErrCodeLifecycle = "Lifecycle"
|
ErrCodeLifecycle = "Lifecycle"
|
||||||
ErrCodeNetwork = "Network"
|
ErrCodeNetwork = "Network"
|
||||||
|
ErrCodeSandboxViolation = "SandboxViolation"
|
||||||
ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed"
|
ErrCodePackageManagerExecutionFailed = "PackageManagerExecutionFailed"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user