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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user