From 171c6aa97d096700250cf40c80403eb4c6b2e8fb Mon Sep 17 00:00:00 2001 From: Ben Gittins <5601410+ihavespoons@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:28:56 +1000 Subject: [PATCH] Match versioned interpreter names, and record denied macOS execs Under `agentmon wrap`, Homebrew's python3 exited 255, and no event said why (SECENG-311). ES reports the resolved executable, and /opt/homebrew/bin/python3 resolves to .../bin/python3.15. agent-default names python3, so the exec fell through to default-deny-commands. The command matcher now also tries the basename without a trailing version suffix such as ".15", after the existing ".real" form. Both match loops in the engine had their own copy of this logic, and only one stripped ".real". They now share compiledCommandRule.matchesCommand. The extension now sends an execve event when the daemon denies an exec, as the Linux execve handler does. The process access and wrap reuse test scripts run plain python3 again. scripts/test-macos-wrap-python.sh failed 3 of 3 on build 1677 and passes 3 of 3 on build 1678. Co-Authored-By: Claude Opus 5.5 --- internal/policy/command_name_forms_test.go | 69 ++++++++ internal/policy/engine.go | 190 ++++++++++----------- macos/AgentMon/ESFClient.swift | 28 ++- scripts/test-macos-process-access.sh | 13 +- scripts/test-macos-wrap-python.sh | 71 ++++++++ scripts/test-macos-wrap-session-reuse.sh | 6 +- 6 files changed, 258 insertions(+), 119 deletions(-) create mode 100644 internal/policy/command_name_forms_test.go create mode 100755 scripts/test-macos-wrap-python.sh diff --git a/internal/policy/command_name_forms_test.go b/internal/policy/command_name_forms_test.go new file mode 100644 index 00000000..974fd4a6 --- /dev/null +++ b/internal/policy/command_name_forms_test.go @@ -0,0 +1,69 @@ +package policy + +import ( + "slices" + "testing" +) + +func TestStripVersionSuffix(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"python3.15", "python3"}, + {"pip3.15", "pip3"}, + {"perl5.34.1", "perl5"}, + {"python3", "python3"}, + {"node", "node"}, + {"bash.real", "bash.real"}, + {"tar.gz", "tar.gz"}, + {"1.2", "1.2"}, + {"v1.2", "v1"}, + {"x.", "x."}, + {".15", ".15"}, + } { + if got := stripVersionSuffix(c.in); got != c.want { + t.Errorf("stripVersionSuffix(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestCommandNameForms(t *testing.T) { + if got := commandNameForms("python3.15"); !slices.Equal(got, []string{"python3.15", "python3"}) { + t.Errorf("python3.15: %v", got) + } + if got := commandNameForms("bash.real"); !slices.Equal(got, []string{"bash.real", "bash"}) { + t.Errorf("bash.real: %v", got) + } + if got := commandNameForms("git"); !slices.Equal(got, []string{"git"}) { + t.Errorf("git: %v", got) + } +} + +// TestVersionedInterpreterMatchesRule covers SECENG-311. On macOS, ES +// reports the resolved executable. Homebrew's python3 resolves to +// .../bin/python3.15, which agent-default denied as default-deny-commands, +// so python3 died under `agentmon wrap`. A deny rule must match the +// versioned name too. +func TestVersionedInterpreterMatchesRule(t *testing.T) { + p := &Policy{Version: 1, CommandRules: []CommandRule{ + {Name: "deny-perl", Commands: []string{"perl5"}, Decision: "deny"}, + {Name: "allow-python", Commands: []string{"python3"}, Decision: "allow"}, + }} + e, err := NewEngine(p, false, true) + if err != nil { + t.Fatal(err) + } + const py = "/opt/homebrew/Cellar/python@3.15/3.15.0/bin/python3.15" + for _, c := range []struct { + name, cmd, rule, decision string + }{ + {"CheckCommand", py, "allow-python", "allow"}, + {"CheckCommand", "/usr/bin/perl5.34", "deny-perl", "deny"}, + } { + d := e.CheckCommand(c.cmd, []string{c.cmd}) + if d.Rule != c.rule || string(d.PolicyDecision) != c.decision { + t.Errorf("CheckCommand(%s) = %s by %q, want %s by %q", c.cmd, d.PolicyDecision, d.Rule, c.decision, c.rule) + } + } + if d := e.CheckExecve(py, []string{py}, 0); d.Rule != "allow-python" { + t.Errorf("CheckExecve(%s) = %s by %q, want allow by allow-python", py, d.PolicyDecision, d.Rule) + } +} diff --git a/internal/policy/engine.go b/internal/policy/engine.go index 8a16efa3..c3eb5810 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -7,8 +7,10 @@ import ( "net" "path/filepath" "regexp" + "slices" "strings" "time" + "unicode" "github.com/diffsec/agentmon/internal/shellparse" "github.com/diffsec/agentmon/pkg/types" @@ -130,6 +132,88 @@ type compiledCommandRule struct { argsRegexes []*regexp.Regexp // Regex patterns matched against joined args string } +// matchesCommand reports whether the rule names command. A rule with no +// commands names every command. A full path or a path glob is checked +// first. Then the basename is checked as it is, and in two other forms: +// +// - Without ".real". The shim install renames the original shell to +// .real and puts the shim at the original path. The shim sends the +// real shell as the command, so a policy that lists `bash` must also +// match `bash.real`. +// - Without a version suffix, such as ".15" in "python3.15". On macOS, ES +// reports the resolved executable, and Homebrew's python3 resolves to +// .../bin/python3.15. A policy that lists `python3` must match it, or +// Homebrew's python3 dies under `agentmon wrap` (SECENG-311). +func (r *compiledCommandRule) matchesCommand(command string) bool { + if len(r.basenames) == 0 && len(r.basenameGlobs) == 0 && len(r.fullPaths) == 0 && len(r.pathGlobs) == 0 { + return true + } + cmdLower := strings.ToLower(command) + if _, ok := r.fullPaths[cmdLower]; ok { + return true + } + for _, g := range r.pathGlobs { + if g.Match(cmdLower) || g.Match(command) { + return true + } + } + + base := filepath.Base(command) + names := commandNameForms(strings.ToLower(base)) + for _, n := range names { + if _, ok := r.basenames[n]; ok { + return true + } + } + for _, g := range r.basenameGlobs { + if g.Match(base) { + return true + } + for _, n := range names { + if g.Match(n) { + return true + } + } + } + return false +} + +// commandNameForms returns a lowercase basename, then the same name without +// ".real", then without a trailing version suffix. Each form is in the list +// once. +func commandNameForms(base string) []string { + forms := []string{base} + add := func(n string) { + if n != "" && !slices.Contains(forms, n) { + forms = append(forms, n) + } + } + noReal := strings.TrimSuffix(base, ".real") + add(noReal) + add(stripVersionSuffix(noReal)) + return forms +} + +// stripVersionSuffix removes trailing "." parts: "python3.15" gives +// "python3", and "perl5.34.1" gives "perl5". A name with no such suffix is +// returned unchanged. The part that stays must hold a letter, so "1.2" +// stays "1.2". +func stripVersionSuffix(name string) string { + out := name + for { + dot := strings.LastIndexByte(out, '.') + if dot <= 0 || dot == len(out)-1 || !strings.ContainsFunc(out[:dot], unicode.IsLetter) { + return out + } + for _, c := range out[dot+1:] { + if c < '0' || c > '9' { + return out + } + } + out = out[:dot] + } +} + type compiledUnixRule struct { rule UnixSocketRule paths []glob.Glob @@ -853,19 +937,6 @@ func decisionStrictness(d types.Decision) int { // default-deny fall-through (kept here so callers that don't care about // match status continue to see deny-by-default). func (e *Engine) matchCommandRules(command string, args []string) (Decision, bool) { - cmdLower := strings.ToLower(command) - cmdBase := strings.ToLower(filepath.Base(command)) - // The shim install renames the original shell to .real and places - // the shim at the original path. Server-side, the shim forwards the - // real shell as the outer command — so an operator policy listing - // `commands: [bash]` would otherwise miss `bash.real` and fall through - // to default-deny on every shim-routed shell invocation. shellparse - // already strips this suffix for known-shell detection (see - // shellparse.basenameLower); apply the same normalization to the - // basename match here so policy entries written without `.real` - // continue to match after a shim install. - cmdBaseNorm := strings.TrimSuffix(cmdBase, ".real") - for _, r := range e.compiledCommandRules { // Pre-check is always depth 0 (direct command from user) // Skip rules that don't apply to direct commands @@ -873,54 +944,7 @@ func (e *Engine) matchCommandRules(command string, args []string) (Decision, boo continue } - // Check if command matches any of the rule's patterns - commandMatched := false - - // If no commands specified, rule applies to all commands - if len(r.basenames) == 0 && len(r.basenameGlobs) == 0 && len(r.fullPaths) == 0 && len(r.pathGlobs) == 0 { - commandMatched = true - } else { - // Check full path matches first (more specific) - if _, ok := r.fullPaths[cmdLower]; ok { - commandMatched = true - } - - // Check path glob patterns - if !commandMatched { - for _, g := range r.pathGlobs { - if g.Match(cmdLower) || g.Match(command) { - commandMatched = true - break - } - } - } - - // Check basename matches (less specific, legacy behavior) - if !commandMatched { - if _, ok := r.basenames[cmdBase]; ok { - commandMatched = true - } else if cmdBaseNorm != cmdBase { - if _, ok := r.basenames[cmdBaseNorm]; ok { - commandMatched = true - } - } - } - - // Check basename glob patterns - if !commandMatched { - for _, g := range r.basenameGlobs { - if g.Match(cmdBase) || g.Match(filepath.Base(command)) { - commandMatched = true - break - } - if cmdBaseNorm != cmdBase && g.Match(cmdBaseNorm) { - commandMatched = true - break - } - } - } - } - + commandMatched := r.matchesCommand(command) if !commandMatched { continue } @@ -1529,55 +1553,13 @@ func (e *Engine) checkExecve(filename string, argv []string, depth int) (dec Dec defer func() { dec.Tor = &tv }() // audit: attach, don't loosen } } - cmdLower := strings.ToLower(filename) - cmdBase := strings.ToLower(filepath.Base(filename)) - for _, r := range e.compiledCommandRules { // Check depth/context constraint first if !r.rule.Context.MatchesDepth(depth) { continue } - // Check if command matches any of the rule's patterns - commandMatched := false - - // If no commands specified, rule applies to all commands - if len(r.basenames) == 0 && len(r.basenameGlobs) == 0 && len(r.fullPaths) == 0 && len(r.pathGlobs) == 0 { - commandMatched = true - } else { - // Check full path matches first (more specific) - if _, ok := r.fullPaths[cmdLower]; ok { - commandMatched = true - } - - // Check path glob patterns - if !commandMatched { - for _, g := range r.pathGlobs { - if g.Match(cmdLower) || g.Match(filename) { - commandMatched = true - break - } - } - } - - // Check basename matches (less specific, legacy behavior) - if !commandMatched { - if _, ok := r.basenames[cmdBase]; ok { - commandMatched = true - } - } - - // Check basename glob patterns - if !commandMatched { - for _, g := range r.basenameGlobs { - if g.Match(cmdBase) || g.Match(filepath.Base(filename)) { - commandMatched = true - break - } - } - } - } - + commandMatched := r.matchesCommand(filename) if !commandMatched { continue } diff --git a/macos/AgentMon/ESFClient.swift b/macos/AgentMon/ESFClient.swift index 6e70f603..a6953ab1 100644 --- a/macos/AgentMon/ESFClient.swift +++ b/macos/AgentMon/ESFClient.swift @@ -954,6 +954,28 @@ private func handleAuthProcessAccess(client: OpaquePointer, event: UnsafePointer ) } +/// Record an exec that the daemon's policy denied, as the Linux execve +/// handler does. Without it, a denied exec under `agentmon wrap` left no +/// trace: the process died, and no event said why (SECENG-311). +private func sendExecDeniedEvent(path: String, args: [String], pid: pid_t, parentPID: pid_t, + sessionID: String, rule: String?, message: String?) { + var fields: [String: Any] = ["argv": args, "parent_pid": Int(parentPID)] + if let message = message, !message.isEmpty { + fields["message"] = message + } + sendFileEvent( + eventType: "execve", + path: path, + operation: "exec", + pid: pid, + sessionID: sessionID, + decision: "deny", + rule: rule, + action: "blocked", + extraFields: ["fields": fields] + ) +} + /// How long to wait for the daemon's exec verdict before failing closed. /// /// Comfortably inside the ES deadline for AUTH_EXEC, and enormous next to the @@ -1111,7 +1133,11 @@ private func handleAuthExec(client: OpaquePointer, event: UnsafePointer&2; exit 2; } - # wrap creates a new agent-default session, with the wrap process as its -# root. A second wrap into the same session is not used, because the daemon -# keeps the first root PID. wrap reads the API key from the environment, -# because root has a different home directory. +# root. test-macos-wrap-session-reuse.sh covers wraps into one session. wrap +# reads the API key from the environment, because root has a different home +# directory. # # run_wrapped prints the probe's last line of output. On a failure the caller # prints the exit code and stderr, which run_wrapped keeps in $WS/err. run_wrapped() { local rc=0 (cd "$WS" && sudo AGENTMON_API_KEY="$API_KEY" "$A" wrap --policy agent-default --report=false -- \ - "$PYAPP" "$WS/access.py" "$@" 2>"$WS/err") > "$WS/out" || rc=$? + "$PY" "$WS/access.py" "$@" 2>"$WS/err") > "$WS/out" || rc=$? echo "exit $rc" >> "$WS/err" tail -n 1 "$WS/out" } diff --git a/scripts/test-macos-wrap-python.sh b/scripts/test-macos-wrap-python.sh new file mode 100755 index 00000000..10dd9246 --- /dev/null +++ b/scripts/test-macos-wrap-python.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Hardware test: Homebrew's python3 runs under `agentmon wrap`, and an exec +# that the policy denies leaves an execve event. +# +# Before build 1678, python3 exited 255 under wrap, and no event said why +# (SECENG-311). /opt/homebrew/bin/python3 resolves to .../bin/python3.15, and +# ES reports that resolved path. agent-default names python3, not +# python3.15, so the exec fell through to default-deny-commands. +# +# Run it on a Mac where AgentMon.app is installed, its extension is enabled, +# and the daemon is running. Run it before each release. +set -euo pipefail + +A=${AGENTMON:-/Applications/AgentMon.app/Contents/MacOS/agentmon} +PY=${PYTHON:-} +if [ -z "$PY" ]; then + for p in /opt/homebrew/bin/python3 /usr/local/bin/python3; do + if [ -x "$p" ]; then PY=$p; break; fi + done +fi +[ -n "$PY" ] || { echo "no python3 found; set PYTHON" >&2; exit 2; } + +WS=$(mktemp -d "${TMPDIR:-/tmp}/ampy.XXXXXX") +WS=$(cd "$WS" && pwd -P) +status=0 +trap 'rm -rf "$WS"' EXIT + +ok() { echo "ok $*"; } +fail() { echo "FAIL $*"; status=1; } + +wrap() { + (cd "$WS" && "$A" wrap --policy agent-default --report=false -- "$@" 2>>"$WS/err") +} + +# --- python3 runs ------------------------------------------------------------ + +rc=0 +got=$(wrap "$PY" -c 'print("ran")') || rc=$? +if [ "$got" = ran ] && [ $rc -eq 0 ]; then + ok "$PY runs under wrap" +else + fail "$PY under wrap: got '$got', exit $rc, want 'ran', exit 0" +fi + +# --- a denied exec leaves an event -------------------------------------------- + +# No agent-default rule names /usr/bin/banner, so default-deny-commands +# denies it. python3 starts it, so the extension, not wrap, makes the check. +# A denied exec after a fork ends the child with SIGKILL, so the probe reads +# the exit status. Outside a session, banner exits 0. +cat > "$WS/deny.py" <<'PY' +import subprocess +try: + r = subprocess.run(["/usr/bin/banner", "x"], capture_output=True) + print("ran" if r.returncode == 0 else "denied") +except PermissionError: + print("denied") +PY +got=$(wrap "$PY" "$WS/deny.py" | tail -n 1) || true +if [ "$got" = denied ]; then ok "an exec of /usr/bin/banner is denied"; else fail "/usr/bin/banner: got '$got', want denied"; fi + +# The events search runs on compact JSON, with no space after a colon. +n=$("$A" events query --since 5m --type execve --text-like '%/usr/bin/banner%' 2>/dev/null \ + | grep -c '"type": "execve"' || true) +if [ "$n" -ge 1 ]; then ok "$n execve event for /usr/bin/banner"; else fail "no execve event for /usr/bin/banner"; fi + +if [ $status -ne 0 ] && [ -s "$WS/err" ]; then + echo "wrap stderr:" + sed 's/^/ /' "$WS/err" +fi +exit $status diff --git a/scripts/test-macos-wrap-session-reuse.sh b/scripts/test-macos-wrap-session-reuse.sh index bccd7eab..51a23c52 100755 --- a/scripts/test-macos-wrap-session-reuse.sh +++ b/scripts/test-macos-wrap-session-reuse.sh @@ -21,10 +21,6 @@ if [ -z "$PY" ]; then done fi [ -n "$PY" ] || { echo "no python3 found; set PYTHON" >&2; exit 2; } -# Homebrew's python3 is a launcher that execs Python.app. Under wrap, that -# second exec dies with no event (SECENG-311), so wrap runs Python.app itself. -PYAPP=$("$PY" -c 'import os, sys; print(os.path.join(sys.base_prefix, "Resources/Python.app/Contents/MacOS/Python"))') -[ -x "$PYAPP" ] || { echo "$PYAPP is missing" >&2; exit 2; } SYS_SOCK=/var/run/usbmuxd [ -S "$SYS_SOCK" ] || { echo "$SYS_SOCK is missing" >&2; exit 2; } @@ -74,7 +70,7 @@ SID=$("$A" session create --workspace "$WS" --policy agent-default | awk '/^Sess [ -n "$SID" ] || { echo "could not create a session" >&2; exit 2; } wrap() { - (cd "$WS" && "$A" wrap --session "$SID" --report=false -- "$PYAPP" "$WS/probe.py" "$@" 2>>"$WS/err") + (cd "$WS" && "$A" wrap --session "$SID" --report=false -- "$PY" "$WS/probe.py" "$@" 2>>"$WS/err") } # The first wrap checks, then waits until the second wrap finished.