diff --git a/internal/cli/policy_cmd.go b/internal/cli/policy_cmd.go index 66608821..13cafa43 100644 --- a/internal/cli/policy_cmd.go +++ b/internal/cli/policy_cmd.go @@ -526,6 +526,34 @@ func macOSPolicyWarnings(p *policy.Policy) []string { out = append(out, fmt.Sprintf("rule=%s: signal decision \"redirect\" is not possible on macOS; the fallback %q applies", r.Name, fb)) } } + return append(out, macOSResourceLimitWarnings(p.ResourceLimits)...) +} + +// macOSResourceLimitWarnings lists the resource_limits that macOS does not +// enforce. macOS has no cgroups. The extension enforces pids_max by denying +// an exec, but ES has no AUTH event for fork, so a plain fork still works. +// The timeouts work on every platform. +func macOSResourceLimitWarnings(l policy.ResourceLimits) []string { + var out []string + unsupported := []struct { + name string + set bool + }{ + {"max_memory_mb", l.MaxMemoryMB > 0}, + {"memory_swap_max_mb", l.MemorySwapMaxMB > 0}, + {"cpu_quota_percent", l.CPUQuotaPercent > 0}, + {"disk_read_bps_max", l.DiskReadBpsMax > 0}, + {"disk_write_bps_max", l.DiskWriteBpsMax > 0}, + {"net_bandwidth_mbps", l.NetBandwidthMbps > 0}, + } + for _, u := range unsupported { + if u.set { + out = append(out, fmt.Sprintf("resource_limits.%s is not enforced on macOS", u.name)) + } + } + if l.PidsMax > 0 { + out = append(out, "resource_limits.pids_max stops an exec on macOS, not a fork") + } return out } diff --git a/internal/cli/policy_config_test.go b/internal/cli/policy_config_test.go index 8fe4e972..4fac3096 100644 --- a/internal/cli/policy_config_test.go +++ b/internal/cli/policy_config_test.go @@ -138,3 +138,21 @@ func TestMacOSResolverSocketWarning_AgentDefault(t *testing.T) { t.Fatalf("agent-default: %s", w) } } + +func TestMacOSResourceLimitWarnings(t *testing.T) { + got := strings.Join(macOSResourceLimitWarnings(policy.ResourceLimits{ + MaxMemoryMB: 512, CPUQuotaPercent: 50, PidsMax: 32, + }), "\n") + for _, want := range []string{"max_memory_mb is not enforced", "cpu_quota_percent is not enforced", + "pids_max stops an exec on macOS, not a fork"} { + if !strings.Contains(got, want) { + t.Errorf("warnings miss %q:\n%s", want, got) + } + } + if strings.Contains(got, "disk_read_bps_max") || strings.Contains(got, "net_bandwidth_mbps") { + t.Errorf("warnings name a limit that the policy does not set:\n%s", got) + } + if w := macOSResourceLimitWarnings(policy.ResourceLimits{}); len(w) != 0 { + t.Errorf("no limits gave warnings: %v", w) + } +} diff --git a/internal/platform/darwin/policysock/handler.go b/internal/platform/darwin/policysock/handler.go index 62cf7da5..4fba35a6 100644 --- a/internal/platform/darwin/policysock/handler.go +++ b/internal/platform/darwin/policysock/handler.go @@ -375,6 +375,7 @@ func (a *PolicyAdapter) BuildPolicySnapshot(sessionID string, clientVersion uint SignalRules: signalSnapshotRules(p.SignalRules), NetworkEnforcement: enforcement, NetworkFailOpen: &failOpen, + PidsMax: p.ResourceLimits.PidsMax, ActiveSessions: activeSessions, } } diff --git a/internal/platform/darwin/policysock/protocol.go b/internal/platform/darwin/policysock/protocol.go index 49ccd5c0..70ce79f3 100644 --- a/internal/platform/darwin/policysock/protocol.go +++ b/internal/platform/darwin/policysock/protocol.go @@ -172,6 +172,11 @@ type PolicyResponse struct { NetworkEnforcement string `json:"network_enforcement,omitempty"` NetworkFailOpen *bool `json:"network_fail_open,omitempty"` + // PidsMax is resource_limits.pids_max. The extension denies an exec in + // the session when the session holds more processes than this. Zero + // means no limit. + PidsMax int `json:"pids_max,omitempty"` + // ActiveSessions lists every registered session, oldest first, so the // extension can notice one it never received a snapshot for. See // SessionTracker.ActiveSessions for why that happens. diff --git a/internal/platform/darwin/policysock/snapshot_test.go b/internal/platform/darwin/policysock/snapshot_test.go index 52616306..12031391 100644 --- a/internal/platform/darwin/policysock/snapshot_test.go +++ b/internal/platform/darwin/policysock/snapshot_test.go @@ -177,3 +177,13 @@ func TestBuildPolicySnapshot_RootPIDs(t *testing.T) { t.Error("the tracker did not record that the snapshot named pid 200") } } + +func TestBuildPolicySnapshot_PidsMax(t *testing.T) { + p := &policy.Policy{Version: 1, ResourceLimits: policy.ResourceLimits{PidsMax: 64}} + if got := snapshotFor(t, p).PidsMax; got != 64 { + t.Errorf("PidsMax = %d, want 64", got) + } + if got := snapshotFor(t, &policy.Policy{Version: 1}).PidsMax; got != 0 { + t.Errorf("a policy with no pids_max sent %d", got) + } +} diff --git a/macos/AgentMon/ESFClient.swift b/macos/AgentMon/ESFClient.swift index 7bfb8b36..48eed2f4 100644 --- a/macos/AgentMon/ESFClient.swift +++ b/macos/AgentMon/ESFClient.swift @@ -1091,6 +1091,19 @@ private func handleAuthExec(client: OpaquePointer, event: UnsafePointer limit { + es_respond_auth_result(client, event, ES_AUTH_RESULT_DENY, false) + sendExecDeniedEvent(path: execPath, args: [execPath], pid: pid, parentPID: parentPID, + sessionID: sessionID, rule: "resource-limit-pids-max", + message: "session holds \(count) processes, pids_max is \(limit)") + return + } + // Everything the daemon needs is extracted here, while the message is // certainly valid, rather than inside the async completion. let argc = es_exec_arg_count(execPtr) diff --git a/macos/AgentMon/SessionPolicyCache.swift b/macos/AgentMon/SessionPolicyCache.swift index 6eba3aab..aae4f5d6 100644 --- a/macos/AgentMon/SessionPolicyCache.swift +++ b/macos/AgentMon/SessionPolicyCache.swift @@ -121,6 +121,10 @@ class SessionCache { /// True when the daemon asked for enforcement rather than observation. var networkBlocking: Bool { networkEnforcement == "block" } + /// resource_limits.pids_max. An exec in the session is denied when the + /// session holds more processes than this. Zero means no limit. + var pidsMax: Int = 0 + init(sessionID: String, rootPID: pid_t, version: UInt64, fileRules: [FileRule], networkRules: [NetworkRule], @@ -361,6 +365,16 @@ class SessionPolicyCache { } } + /// The process limit of a session and the number of processes that it + /// holds now. NOTIFY_FORK adds a process and NOTIFY_EXIT removes it. + /// Returns nil when the session has no limit. + func processLimit(sessionID: String) -> (limit: Int, count: Int)? { + queue.sync { + guard let cache = sessions[sessionID], cache.pidsMax > 0 else { return nil } + return (cache.pidsMax, cache.sessionPIDs.count) + } + } + /// Returns the SessionCache for a session ID, or nil if not found. func cacheForSession(_ sessionID: String) -> SessionCache? { queue.sync { sessions[sessionID] } @@ -1230,6 +1244,7 @@ extension SessionCache { ) cache.unixSocketRules = parseUnixSocketRules(json["unix_socket_rules"]) cache.signalRules = parseSignalRules(json["signal_rules"]) + cache.pidsMax = max(0, json["pids_max"] as? Int ?? 0) cache.rootPIDs = parseRootPIDs(json["root_pids"], rootPID: rootPID) cache.sessionPIDs.formUnion(cache.rootPIDs) return cache diff --git a/scripts/test-macos-pids-max.sh b/scripts/test-macos-pids-max.sh new file mode 100755 index 00000000..019e7161 --- /dev/null +++ b/scripts/test-macos-pids-max.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Hardware test: the extension enforces resource_limits.pids_max. An exec in +# a session that holds more processes than pids_max is denied, and an +# execve event records it. +# +# agent-default sets pids_max to 500. The probe forks children that sleep, +# so that the session holds more than 500 processes, and then runs +# /bin/echo. ES has no AUTH event for fork, so the forks themselves work. +# After the children exit, /bin/echo runs again. +# +# 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; } + +START=$(date -u +%Y-%m-%dT%H:%M:%SZ) +WS=$(mktemp -d "${TMPDIR:-/tmp}/ampids.XXXXXX") +WS=$(cd "$WS" && pwd -P) +status=0 +trap 'rm -rf "$WS"' EXIT + +ok() { echo "ok $*"; } +fail() { echo "FAIL $*"; status=1; } + +# The probe prints one word for each run of /bin/echo: "ran" or "denied". +# A denied exec after a fork ends the child with SIGKILL. +cat > "$WS/pids.py" <<'PY' +import os, signal, subprocess, sys, time + +def echo(): + r = subprocess.run(["/bin/echo", "x"], capture_output=True) + return "ran" if r.returncode == 0 else "denied" + +before = echo() +children = [] +for _ in range(int(sys.argv[1])): + pid = os.fork() + if pid == 0: + time.sleep(60) + os._exit(0) + children.append(pid) +time.sleep(1) +over = echo() +for pid in children: + os.kill(pid, signal.SIGKILL) +for pid in children: + os.waitpid(pid, 0) +time.sleep(1) +print(before, over, echo()) +PY + +got=$(cd "$WS" && "$A" wrap --policy agent-default --report=false -- "$PY" "$WS/pids.py" 520 2>>"$WS/err" | tail -n 1) || true +if [ "$got" = "ran denied ran" ]; then + ok "/bin/echo runs, is denied above pids_max 500, and runs again after the children exit" +else + fail "got '$got', want 'ran denied ran'" +fi + +# The events search runs on compact JSON, with no space after a colon. +n=$("$A" events query --since "$START" --type execve --text-like '%resource-limit-pids-max%' 2>/dev/null \ + | grep -c '"type": "execve"' || true) +if [ "$n" -ge 1 ]; then ok "$n execve event from the pids_max limit"; else fail "no execve event from the pids_max limit"; fi + +if [ $status -ne 0 ] && [ -s "$WS/err" ]; then + echo "wrap stderr:" + sed 's/^/ /' "$WS/err" +fi +exit $status