@expo/build-tools 24.2.0 → 24.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/common/projectSources.js +3 -0
  2. package/dist/steps/easFunctions.js +2 -0
  3. package/dist/steps/functions/startIosSimulator.js +34 -2
  4. package/dist/steps/functions/startLocalEgress.d.ts +4 -3
  5. package/dist/steps/functions/startLocalEgress.js +10 -6
  6. package/dist/steps/functions/startSandbox.d.ts +4 -0
  7. package/dist/steps/functions/startSandbox.js +93 -0
  8. package/dist/steps/utils/localEgress.d.ts +24 -0
  9. package/dist/steps/utils/localEgress.js +59 -1
  10. package/dist/steps/utils/localEgressGuard.d.ts +179 -0
  11. package/dist/steps/utils/localEgressGuard.js +537 -0
  12. package/dist/steps/utils/localEgressSession.js +4 -0
  13. package/dist/steps/utils/sandboxDaemon.d.ts +13 -0
  14. package/dist/steps/utils/sandboxDaemon.js +90 -0
  15. package/dist/utils/IosSimulatorUtils.d.ts +34 -0
  16. package/dist/utils/IosSimulatorUtils.js +62 -0
  17. package/package.json +8 -4
  18. package/resources/egress-guard/README.md +83 -0
  19. package/resources/egress-guard/build.sh +30 -0
  20. package/resources/egress-guard/check.c +112 -0
  21. package/resources/egress-guard/guard.c +274 -0
  22. package/resources/egress-guard/policy.c +164 -0
  23. package/resources/egress-guard/policy.h +60 -0
  24. package/resources/egress-guard/tests/guard_insert_test.c +291 -0
  25. package/resources/egress-guard/tests/guard_test.c +177 -0
  26. package/resources/egress-guard/tests/nettest.swift +172 -0
  27. package/resources/egress-guard/tests/policy_test.c +143 -0
  28. package/resources/egress-guard/tests/run-guard-tests.sh +62 -0
  29. package/resources/egress-guard/tests/run-policy-tests.sh +7 -0
@@ -52,6 +52,29 @@ export declare namespace IosSimulatorUtils {
52
52
  deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
53
53
  env: NodeJS.ProcessEnv;
54
54
  }): Promise<void>;
55
+ /**
56
+ * The UDID for a device name or UDID. A name picks the first available
57
+ * device with that name, as `simctl` itself does.
58
+ */
59
+ export function resolveUdidAsync({ deviceIdentifier, env, }: {
60
+ deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
61
+ env: NodeJS.ProcessEnv;
62
+ }): Promise<IosSimulatorUuid>;
63
+ /**
64
+ * Start booting without waiting for boot to complete; follow with
65
+ * `startAsync` to wait for it. `launchdEnvironment` is handed to the
66
+ * simulator's launchd before it spawns anything: `simctl` forwards every
67
+ * `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
68
+ * it starts, and for `boot` that process is launchd itself. This is the only
69
+ * way to give the first processes of a boot an environment; `launchctl
70
+ * setenv` after boot only reaches processes started later. A device that is
71
+ * already booted keeps its environment.
72
+ */
73
+ export function bootAsync({ deviceIdentifier, env, launchdEnvironment, }: {
74
+ deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
75
+ env: NodeJS.ProcessEnv;
76
+ launchdEnvironment?: Record<string, string>;
77
+ }): Promise<void>;
55
78
  export function startAsync({ deviceIdentifier, env, }: {
56
79
  deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
57
80
  env: NodeJS.ProcessEnv;
@@ -66,6 +89,17 @@ export declare namespace IosSimulatorUtils {
66
89
  udid: IosSimulatorUuid;
67
90
  env: NodeJS.ProcessEnv;
68
91
  }): Promise<void>;
92
+ /**
93
+ * Set environment variables in the Simulator's launchd. Every process that
94
+ * launchd spawns afterwards inherits them: apps launched by SpringBoard
95
+ * (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
96
+ * Processes that are already running keep their environment.
97
+ */
98
+ export function setLaunchdEnvironmentAsync({ udid, env, variables, }: {
99
+ udid: IosSimulatorUuid;
100
+ env: NodeJS.ProcessEnv;
101
+ variables: Record<string, string>;
102
+ }): Promise<void>;
69
103
  export function collectLogsAsync({ deviceIdentifier, env, }: {
70
104
  deviceIdentifier: IosSimulatorName | IosSimulatorUuid;
71
105
  env: NodeJS.ProcessEnv;
@@ -87,6 +87,50 @@ var IosSimulatorUtils;
87
87
  }
88
88
  }
89
89
  IosSimulatorUtils.enableAccessibilitySettingsAsync = enableAccessibilitySettingsAsync;
90
+ const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
91
+ /**
92
+ * The UDID for a device name or UDID. A name picks the first available
93
+ * device with that name, as `simctl` itself does.
94
+ */
95
+ async function resolveUdidAsync({ deviceIdentifier, env, }) {
96
+ if (UDID_PATTERN.test(deviceIdentifier)) {
97
+ return deviceIdentifier;
98
+ }
99
+ const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
100
+ const device = devices.find(candidate => candidate.name === deviceIdentifier);
101
+ if (!device) {
102
+ throw new eas_build_job_1.UserError('EAS_IOS_SIMULATOR_NOT_FOUND', `No available iOS Simulator is named "${deviceIdentifier}". Run \`xcrun simctl list devices available\` on the device host to see the devices it offers.`);
103
+ }
104
+ return device.udid;
105
+ }
106
+ IosSimulatorUtils.resolveUdidAsync = resolveUdidAsync;
107
+ /**
108
+ * Start booting without waiting for boot to complete; follow with
109
+ * `startAsync` to wait for it. `launchdEnvironment` is handed to the
110
+ * simulator's launchd before it spawns anything: `simctl` forwards every
111
+ * `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
112
+ * it starts, and for `boot` that process is launchd itself. This is the only
113
+ * way to give the first processes of a boot an environment; `launchctl
114
+ * setenv` after boot only reaches processes started later. A device that is
115
+ * already booted keeps its environment.
116
+ */
117
+ async function bootAsync({ deviceIdentifier, env, launchdEnvironment = {}, }) {
118
+ const bootEnv = { ...env };
119
+ for (const [name, value] of Object.entries(launchdEnvironment)) {
120
+ bootEnv[`SIMCTL_CHILD_${name}`] = value;
121
+ }
122
+ try {
123
+ await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'boot', deviceIdentifier], { env: bootEnv, stdio: 'pipe' });
124
+ }
125
+ catch (err) {
126
+ const failed = err;
127
+ if (/current state: Booted/.test(failed.stderr ?? '')) {
128
+ return;
129
+ }
130
+ throw err;
131
+ }
132
+ }
133
+ IosSimulatorUtils.bootAsync = bootAsync;
90
134
  async function startAsync({ deviceIdentifier, env, }) {
91
135
  const bootstatusResult = await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'bootstatus', deviceIdentifier, '-b'], {
92
136
  env,
@@ -154,6 +198,24 @@ var IosSimulatorUtils;
154
198
  throw lastError ?? new eas_build_job_1.SystemError('Unable to disable apsd in the Simulator.');
155
199
  }
156
200
  IosSimulatorUtils.disableApsdAsync = disableApsdAsync;
201
+ /**
202
+ * Set environment variables in the Simulator's launchd. Every process that
203
+ * launchd spawns afterwards inherits them: apps launched by SpringBoard
204
+ * (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
205
+ * Processes that are already running keep their environment.
206
+ */
207
+ async function setLaunchdEnvironmentAsync({ udid, env, variables, }) {
208
+ // One invocation for every variable: each `simctl spawn` costs a few
209
+ // hundred milliseconds on a device host, and this runs in the window
210
+ // between `simctl boot` returning and launchd spawning the boot's
211
+ // processes, which must inherit these.
212
+ const pairs = Object.entries(variables).flat();
213
+ if (pairs.length === 0) {
214
+ return;
215
+ }
216
+ await (0, turtle_spawn_1.default)('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', ...pairs], { env });
217
+ }
218
+ IosSimulatorUtils.setLaunchdEnvironmentAsync = setLaunchdEnvironmentAsync;
157
219
  async function collectLogsAsync({ deviceIdentifier, env, }) {
158
220
  const outputDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'ios-simulator-logs-'));
159
221
  const outputPath = node_path_1.default.join(outputDir, `${deviceIdentifier}.logarchive`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/build-tools",
3
- "version": "24.2.0",
3
+ "version": "24.4.0",
4
4
  "bugs": "https://github.com/expo/eas-cli/issues",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Expo <support@expo.io>",
@@ -24,6 +24,8 @@
24
24
  "prebuild": "yarn gql",
25
25
  "build": "tsc",
26
26
  "build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
27
+ "build:egress-guard": "resources/egress-guard/build.sh",
28
+ "test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
27
29
  "typecheck": "tsc",
28
30
  "generate-appium-commands": "mise exec node@22.22.0 -- node scripts/generate-appium-commands.js",
29
31
  "prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
@@ -40,14 +42,14 @@
40
42
  "@expo/config": "55.0.10",
41
43
  "@expo/config-plugins": "55.0.7",
42
44
  "@expo/downloader": "24.0.0",
43
- "@expo/eas-build-job": "24.2.0",
45
+ "@expo/eas-build-job": "24.3.0",
44
46
  "@expo/env": "^0.4.0",
45
47
  "@expo/logger": "24.0.0",
46
48
  "@expo/package-manager": "1.9.10",
47
49
  "@expo/plist": "^0.3.5",
48
50
  "@expo/results": "^1.0.0",
49
51
  "@expo/spawn-async": "1.7.2",
50
- "@expo/steps": "24.2.0",
52
+ "@expo/steps": "24.3.0",
51
53
  "@expo/template-file": "24.0.0",
52
54
  "@expo/turtle-spawn": "24.0.0",
53
55
  "@expo/xcpretty": "^4.3.1",
@@ -75,6 +77,7 @@
75
77
  "semver": "^7.6.2",
76
78
  "tar": "7.5.19",
77
79
  "uuid": "9.0.1",
80
+ "ws": "8.21.1",
78
81
  "yaml": "^2.8.1",
79
82
  "zod": "^4.3.5"
80
83
  },
@@ -92,6 +95,7 @@
92
95
  "@types/retry": "^0.12.5",
93
96
  "@types/semver": "^7.5.8",
94
97
  "@types/uuid": "^9.0.8",
98
+ "@types/ws": "8.5.10",
95
99
  "jest": "^29.7.0",
96
100
  "memfs": "^4.17.1",
97
101
  "nock": "13.4.0",
@@ -102,5 +106,5 @@
102
106
  "typescript": "^5.5.4",
103
107
  "uuid": "^9.0.1"
104
108
  },
105
- "gitHead": "97553334d4275d9f18ec23b92e6c3ebe9593767e"
109
+ "gitHead": "e7efe143366b0c908814e61dd3fb4b207600b773"
106
110
  }
@@ -0,0 +1,83 @@
1
+ # egress-guard
2
+
3
+ A small dylib injected into every process the iOS Simulator launches during a
4
+ `--egress local` session. It interposes the socket calls that open outbound
5
+ traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
6
+ variants of the last three that libsystem exports alongside them) and refuses
7
+ any destination that is not loopback. A sockaddr whose family was left
8
+ `AF_UNSPEC` is classified as the family its length implies, which is how the
9
+ kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
10
+ `connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
11
+ forwards live on loopback, so everything that honors the proxy keeps working
12
+ and everything that bypasses it fails with `ECONNREFUSED` in the process that
13
+ tried, with the calling frameworks recorded.
14
+
15
+ Why interposing: simulator processes share the worker's uid, so a packet
16
+ filter cannot tell them apart from the worker's own traffic. dyld interposing
17
+ from an inserted library reaches calls made inside Apple's own frameworks
18
+ (CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
19
+ inside the simulator makes launchd hand the library to every process it
20
+ spawns. The simulator does not enforce library validation, which is what makes
21
+ this possible there and nowhere else.
22
+
23
+ Configuration comes from the environment the simulator's launchd provides:
24
+
25
+ - `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
26
+ - `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
27
+ or `log` (observe only).
28
+
29
+ Each event is one tab-separated line:
30
+
31
+ ```
32
+ eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
33
+ ```
34
+
35
+ A process lists each `(function, peer)` once, up to 128 of them. When that
36
+ table fills it writes one more line with function `overflow` and the limit as
37
+ the peer; further distinct destinations are still refused but not listed.
38
+
39
+ Layout:
40
+
41
+ - `policy.c` / `policy.h`: classification, mode, formatting, per-process
42
+ dedupe. Pure C, host-testable.
43
+ - `guard.c`: the interposers and the constructor that reads the environment.
44
+ - `check.c`: `egress-guard-check`, run inside the simulator right after the
45
+ guard is installed. Exits non-zero unless the library is loaded in a fresh
46
+ process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
47
+ `connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
48
+ - `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
49
+ - `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
50
+ interposers themselves, `tests/run-guard-tests.sh`. The first compiles
51
+ `guard.c` in and forces the lock schedules (a signal handler making a socket
52
+ call inside the critical section, a fork while another thread holds the lock,
53
+ contention); the second inserts the built library like the simulator does and
54
+ checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
55
+ UDP disconnect. Nothing in them opens a socket to a non-loopback address.
56
+ - `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
57
+ a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
58
+ - `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
59
+
60
+ Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
61
+ variable of its own environment to the process it starts, and for `simctl
62
+ boot` that process is the simulator's launchd, so the worker boots with the
63
+ guard and proxy variables in that form and every process of the boot inherits
64
+ them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
65
+ device that was already booted, but by then the boot's own processes have
66
+ started without it. It also cannot change a variable the boot already carried:
67
+ a device booted with one guard configuration keeps it until it is shut down,
68
+ which is why the worker boots devices itself and every configuration in the
69
+ end-to-end test gets its own boot. The self-check runs once boot completes and is followed
70
+ by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
71
+ `packages/worker/package.sh` for the iOS worker tarball and by the
72
+ `test-egress-guard` EAS workflow, not committed.
73
+
74
+ Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
75
+ self-check fails the session, since a `--egress local` session without the
76
+ guard would silently leak. Only an unwritable event log is a warning, because
77
+ refusals still happen and only the reporting is lost. Recording is best-effort
78
+ in the process too: the event log is opened per event rather than held open
79
+ (a process that closes descriptors it does not own, as `launchd_sim` does,
80
+ would otherwise make the guard write into whatever reused the number), and a
81
+ dedupe slot that cannot be taken within a bounded number of attempts (a signal
82
+ handler re-entering the guard, a fork child inheriting a held lock) skips the
83
+ event rather than blocking or aborting the process.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
3
+ # packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")"
6
+ # Output directory; defaults to packages/build-tools/bin.
7
+ bin_dir="${1:-../../bin}"
8
+ mkdir -p "$bin_dir"
9
+ out=$(mktemp -d)
10
+ sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
11
+ for arch in arm64 x86_64; do
12
+ xcrun --sdk iphonesimulator clang \
13
+ -std=c11 -Wall -Wextra -Werror -O2 \
14
+ -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
15
+ -dynamiclib -install_name @rpath/egress-guard.dylib \
16
+ -o "$out/egress-guard-$arch.dylib" policy.c guard.c
17
+ done
18
+ lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
19
+ lipo -info "$bin_dir/egress-guard.dylib"
20
+
21
+ # The self-check the worker runs inside the simulator after installing the guard.
22
+ for arch in arm64 x86_64; do
23
+ xcrun --sdk iphonesimulator clang \
24
+ -std=c11 -Wall -Wextra -Werror -O2 \
25
+ -target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
26
+ -o "$out/egress-guard-check-$arch" check.c
27
+ done
28
+ lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
29
+ chmod +x "$bin_dir/egress-guard-check"
30
+ lipo -info "$bin_dir/egress-guard-check"
@@ -0,0 +1,112 @@
1
+ // Self-check run inside a simulator right after the guard is installed:
2
+ // proves the guard library is loaded in a freshly spawned process and that it
3
+ // behaves as the requested mode says, on every connect entry point the guard
4
+ // covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
5
+ // loaded but did not behave; 1 on usage errors.
6
+ //
7
+ // egress-guard-check --mode block|log
8
+ #include <arpa/inet.h>
9
+ #include <dlfcn.h>
10
+ #include <errno.h>
11
+ #include <fcntl.h>
12
+ #include <mach-o/dyld.h>
13
+ #include <netinet/in.h>
14
+ #include <stdio.h>
15
+ #include <string.h>
16
+ #include <sys/socket.h>
17
+ #include <unistd.h>
18
+
19
+ // TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
20
+ // connect reports EINPROGRESS and nothing ever answers.
21
+ #define PROBE_ADDRESS "192.0.2.1"
22
+ #define PROBE_PORT 9
23
+
24
+ typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
25
+
26
+ static int guard_loaded(void) {
27
+ for (uint32_t i = 0; i < _dyld_image_count(); i++) {
28
+ const char *name = _dyld_get_image_name(i);
29
+ if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
30
+ return 1;
31
+ }
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ // One non-blocking connect to the probe address through `entry`, with the
37
+ // sockaddr family the caller wants. Returns the connect result; errno is
38
+ // stored in *err.
39
+ static int probe(connect_fn entry, sa_family_t family, int *err) {
40
+ int fd = socket(AF_INET, SOCK_STREAM, 0);
41
+ if (fd < 0) {
42
+ *err = errno;
43
+ return -2;
44
+ }
45
+ fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
46
+ struct sockaddr_in to;
47
+ memset(&to, 0, sizeof to);
48
+ to.sin_family = family;
49
+ to.sin_len = sizeof to;
50
+ to.sin_port = htons(PROBE_PORT);
51
+ inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
52
+ int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
53
+ *err = errno;
54
+ close(fd);
55
+ return rc;
56
+ }
57
+
58
+ int main(int argc, char **argv) {
59
+ const char *mode = "block";
60
+ if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
61
+ mode = argv[2];
62
+ } else if (argc != 1) {
63
+ fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
64
+ return 1;
65
+ }
66
+ if (!guard_loaded()) {
67
+ printf("egress-guard-check: the guard library is not loaded in this process\n");
68
+ return 2;
69
+ }
70
+ connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
71
+ if (connect_nocancel == NULL) {
72
+ printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
73
+ return 3;
74
+ }
75
+
76
+ // The plain call, the non-cancelable variant, and a sockaddr whose family
77
+ // was left AF_UNSPEC, which the kernel connects as IPv4.
78
+ const struct {
79
+ const char *name;
80
+ connect_fn entry;
81
+ sa_family_t family;
82
+ } probes[] = {
83
+ {"connect", connect, AF_INET},
84
+ {"connect$NOCANCEL", connect_nocancel, AF_INET},
85
+ {"connect with AF_UNSPEC", connect, AF_UNSPEC},
86
+ };
87
+ int block = strcmp(mode, "block") == 0;
88
+ for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
89
+ int err = 0;
90
+ int rc = probe(probes[i].entry, probes[i].family, &err);
91
+ if (rc == -2) {
92
+ printf("egress-guard-check: socket() failed: %s\n", strerror(err));
93
+ return 3;
94
+ }
95
+ if (block && !(rc == -1 && err == ECONNREFUSED)) {
96
+ printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
97
+ probes[i].name, rc, err, strerror(err));
98
+ return 3;
99
+ }
100
+ if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
101
+ printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
102
+ probes[i].name, err, strerror(err));
103
+ return 3;
104
+ }
105
+ }
106
+ if (block) {
107
+ printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
108
+ } else {
109
+ printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
110
+ }
111
+ return 0;
112
+ }
@@ -0,0 +1,274 @@
1
+ // The local egress guard: interposes the calls that open outbound traffic in
2
+ // every simulator process it is inserted into, refuses non-loopback
3
+ // destinations in block mode, and records one event per destination per
4
+ // process. See README.md and policy.h.
5
+ #include "policy.h"
6
+
7
+ #include <dlfcn.h>
8
+ #include <errno.h>
9
+ #include <execinfo.h>
10
+ #include <fcntl.h>
11
+ #include <os/lock.h>
12
+ #include <sched.h>
13
+ #include <stdio.h>
14
+ #include <stdlib.h>
15
+ #include <string.h>
16
+ #include <sys/socket.h>
17
+ #include <sys/types.h>
18
+ #include <unistd.h>
19
+
20
+ #define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
21
+ #define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
22
+ #define EG_MAX_CALLERS 6
23
+ #define EG_CALLER_LENGTH 64
24
+ #define EG_LOCK_ATTEMPTS 64
25
+
26
+ static int eg_initialized = 0;
27
+ static void *eg_own_image = NULL;
28
+ static char eg_log_path[1024];
29
+ static eg_mode_t eg_mode = EG_MODE_BLOCK;
30
+ static eg_seen_t eg_seen;
31
+ static int eg_overflow_reported = 0;
32
+ static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
33
+
34
+ static void eg_init(void) {
35
+ if (eg_initialized) {
36
+ return;
37
+ }
38
+ eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
39
+ Dl_info self;
40
+ if (dladdr((const void *)eg_init, &self)) {
41
+ eg_own_image = self.dli_fbase;
42
+ }
43
+ const char *log_path = getenv(EG_LOG_ENV);
44
+ // The path is kept rather than an open descriptor: a process that closes
45
+ // descriptors it does not know about (launchd_sim does) would otherwise
46
+ // leave the guard writing into whatever file reused the number.
47
+ if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
48
+ strcpy(eg_log_path, log_path);
49
+ }
50
+ eg_initialized = 1;
51
+ }
52
+
53
+ __attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
54
+
55
+ // Image names of the frames above the guard's own, innermost first, with
56
+ // consecutive repeats collapsed: "Network,CFNetwork,MyApp".
57
+ __attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
58
+ void *frames[EG_MAX_CALLERS + 6];
59
+ int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
60
+ int count = 0;
61
+ for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
62
+ Dl_info info;
63
+ const char *image = "?";
64
+ if (dladdr(frames[i], &info)) {
65
+ if (info.dli_fbase == eg_own_image) {
66
+ continue;
67
+ }
68
+ if (info.dli_fname != NULL) {
69
+ const char *slash = strrchr(info.dli_fname, '/');
70
+ image = slash ? slash + 1 : info.dli_fname;
71
+ }
72
+ }
73
+ if (count > 0 && strcmp(names[count - 1], image) == 0) {
74
+ continue;
75
+ }
76
+ strncpy(names[count], image, EG_CALLER_LENGTH - 1);
77
+ names[count][EG_CALLER_LENGTH - 1] = '\0';
78
+ count++;
79
+ }
80
+ return count;
81
+ }
82
+
83
+ // A lock held by this same thread (a signal handler making a socket call
84
+ // while the interrupted call held it) or by a thread that did not survive
85
+ // fork() never becomes available and os_unfair_lock_lock aborts the process
86
+ // in both cases. Give up on recording the event instead; refusal does not
87
+ // depend on it. Ordinary contention over the microsecond-long critical
88
+ // section resolves within the first attempts.
89
+ static int eg_lock_bounded(void) {
90
+ for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
91
+ if (os_unfair_lock_trylock(&eg_lock)) {
92
+ return 1;
93
+ }
94
+ sched_yield();
95
+ }
96
+ return 0;
97
+ }
98
+
99
+ // One line in the event log. O_APPEND keeps whole lines intact across
100
+ // processes writing concurrently; the log is opened per event, at most
101
+ // EG_SEEN_CAPACITY + 1 times per process.
102
+ __attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
103
+ const char *peer, int with_callers) {
104
+ if (eg_log_path[0] == '\0') {
105
+ return;
106
+ }
107
+ char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
108
+ const char *callers[EG_MAX_CALLERS];
109
+ int caller_count = with_callers ? eg_collect_callers(names) : 0;
110
+ for (int i = 0; i < caller_count; i++) {
111
+ callers[i] = names[i];
112
+ }
113
+ char line[1024];
114
+ int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
115
+ callers, caller_count);
116
+ if (n <= 0) {
117
+ return;
118
+ }
119
+ int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
120
+ if (fd >= 0) {
121
+ (void)write(fd, line, (size_t)n);
122
+ close(fd);
123
+ }
124
+ }
125
+
126
+ // Returns 1 when the call must be refused.
127
+ __attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
128
+ socklen_t len) {
129
+ eg_class_t cls = eg_classify(sa, len);
130
+ if (cls != EG_REMOTE) {
131
+ return 0;
132
+ }
133
+ eg_init();
134
+ int deny = eg_should_deny(eg_mode, cls);
135
+
136
+ char peer[96];
137
+ if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
138
+ strncpy(peer, "?", sizeof peer);
139
+ }
140
+ char key[EG_SEEN_KEY_LENGTH];
141
+ snprintf(key, sizeof key, "%s %s", function, peer);
142
+ int fresh = 0;
143
+ int overflowed = 0;
144
+ if (eg_lock_bounded()) {
145
+ fresh = eg_seen_insert(&eg_seen, key);
146
+ if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
147
+ eg_overflow_reported = 1;
148
+ overflowed = 1;
149
+ }
150
+ os_unfair_lock_unlock(&eg_lock);
151
+ }
152
+
153
+ const char *action = deny ? "blocked" : "logged";
154
+ if (fresh) {
155
+ eg_write_event(function, action, peer, 1);
156
+ } else if (overflowed) {
157
+ // Once per process: the table is full, so further distinct destinations
158
+ // are still refused but no longer listed.
159
+ char limit[64];
160
+ snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
161
+ eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
162
+ }
163
+ return deny;
164
+ }
165
+
166
+ // connect() with an AF_UNSPEC address on a datagram socket dissolves the
167
+ // association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
168
+ // to the kernel. On a stream socket the kernel connects to the address the
169
+ // length implies, which eg_handle classifies.
170
+ static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
171
+ if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
172
+ return 0;
173
+ }
174
+ int type = 0;
175
+ socklen_t type_len = sizeof type;
176
+ return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
177
+ }
178
+
179
+ typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
180
+ typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
181
+ socklen_t);
182
+ typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
183
+
184
+ // The non-cancelable variants libsystem_kernel exports next to the cancelable
185
+ // ones; dyld interposing is per symbol, so each needs its own entry.
186
+ extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
187
+ "_connect$NOCANCEL");
188
+ extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
189
+ const struct sockaddr *, socklen_t) __asm__(
190
+ "_sendto$NOCANCEL");
191
+ extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
192
+ "_sendmsg$NOCANCEL");
193
+
194
+ static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
195
+ socklen_t len) {
196
+ if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
197
+ errno = ECONNREFUSED;
198
+ return -1;
199
+ }
200
+ return original(fd, sa, len);
201
+ }
202
+
203
+ static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
204
+ return eg_connect_through(connect, fd, sa, len);
205
+ }
206
+
207
+ static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
208
+ return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
209
+ }
210
+
211
+ static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
212
+ unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
213
+ size_t *len, sae_connid_t *connid) {
214
+ if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
215
+ !eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
216
+ eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
217
+ errno = ECONNREFUSED;
218
+ return -1;
219
+ }
220
+ return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
221
+ }
222
+
223
+ static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
224
+ int flags, const struct sockaddr *sa, socklen_t len) {
225
+ if (sa != NULL && eg_handle("sendto", sa, len)) {
226
+ errno = ECONNREFUSED;
227
+ return -1;
228
+ }
229
+ return original(fd, buf, n, flags, sa, len);
230
+ }
231
+
232
+ static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
233
+ socklen_t len) {
234
+ return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
235
+ }
236
+
237
+ static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
238
+ const struct sockaddr *sa, socklen_t len) {
239
+ return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
240
+ }
241
+
242
+ static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
243
+ int flags) {
244
+ if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
245
+ eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
246
+ errno = ECONNREFUSED;
247
+ return -1;
248
+ }
249
+ return original(fd, msg, flags);
250
+ }
251
+
252
+ static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
253
+ return eg_sendmsg_through(sendmsg, fd, msg, flags);
254
+ }
255
+
256
+ static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
257
+ return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
258
+ }
259
+
260
+ // dyld applies these to every image in the process, including the shared
261
+ // cache, which is how calls made inside CFNetwork and Network.framework are
262
+ // caught.
263
+ __attribute__((used)) static const struct {
264
+ const void *replacement;
265
+ const void *original;
266
+ } eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
267
+ {(const void *)eg_connect, (const void *)connect},
268
+ {(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
269
+ {(const void *)eg_connectx, (const void *)connectx},
270
+ {(const void *)eg_sendto, (const void *)sendto},
271
+ {(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
272
+ {(const void *)eg_sendmsg, (const void *)sendmsg},
273
+ {(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
274
+ };