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.