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
28 changes: 28 additions & 0 deletions internal/cli/policy_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
18 changes: 18 additions & 0 deletions internal/cli/policy_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions internal/platform/darwin/policysock/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
5 changes: 5 additions & 0 deletions internal/platform/darwin/policysock/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions internal/platform/darwin/policysock/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
13 changes: 13 additions & 0 deletions macos/AgentMon/ESFClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,19 @@ private func handleAuthExec(client: OpaquePointer, event: UnsafePointer<es_messa
}
let parentPID = event.pointee.process.pointee.ppid

// resource_limits.pids_max. ES has no AUTH event for fork, so the limit
// stops new programs, not a plain fork. The process that execs is
// already counted, so the session is over the limit only when it holds
// more processes than pids_max.
if let (limit, count) = SessionPolicyCache.shared.processLimit(sessionID: sessionID),
count > 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)
Expand Down
15 changes: 15 additions & 0 deletions macos/AgentMon/SessionPolicyCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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] }
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions scripts/test-macos-pids-max.sh
Original file line number Diff line number Diff line change
@@ -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
Loading