Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions internal/policy/command_name_forms_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
190 changes: 86 additions & 104 deletions internal/policy/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
// <name>.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 ".<digits>" 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
Expand Down Expand Up @@ -853,74 +937,14 @@ 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 <name>.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
if !r.rule.Context.MatchesDepth(0) {
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
}
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 27 additions & 1 deletion macos/AgentMon/ESFClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1111,7 +1133,11 @@ private func handleAuthExec(client: OpaquePointer, event: UnsafePointer<es_messa
])

case "deny":
responder.respond(ES_AUTH_RESULT_DENY)
if responder.respond(ES_AUTH_RESULT_DENY) {
sendExecDeniedEvent(path: execPath, args: args, pid: pid, parentPID: parentPID,
sessionID: sessionID, rule: response?["rule"] as? String,
message: response?["message"] as? String)
}

default:
// A nil response is a socket failure; an unrecognised action is
Expand Down
13 changes: 4 additions & 9 deletions scripts/test-macos-process-access.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,22 +109,17 @@ else
fail "root outside the session: got '$outside', want 'outside allowed allowed allowed'"
fi

# Homebrew's python3 is a launcher that execs Python.app. Under wrap, that
# second exec dies with no event, so wrap runs the Python.app binary 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; }

# 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"
}
Expand Down
Loading
Loading