Audit Process Environment Leakage and Isolate Secrets in Cloud Mac CI

Audit Process Environment Leakage and Isolate Secrets in Cloud Mac CI

When a cloud Mac handles code checkout, builds, tests, and releases, the boundary most easily overlooked is not the directory structure but the process environment. Once a pipeline stores a token in an environment variable, xcodebuild, scripts, test hosts, and background child processes inherit it by default. Even after the main job has finished, a helper process that remains running may still retain the complete environment. The right approach is not to ban environment variables, but to minimize how long secrets remain visible and which processes can access them.

Map How Variables Propagate Between Processes

Every process started by the runner should be classified as a build, test, release, or utility process. Build and test processes generally do not need release tokens, and utilities should not inherit them either. Record each variable name, its injection stage, the command that uses it, and the expected cleanup point—but never record the actual value.

Inspection target Common risk Acceptance test
Runner parent process Receives every secret at startup Check only whether variable names exist
Build child process Unconditionally inherits the parent environment Run an inheritance test with a random marker
launchd domain Retains values across jobs after setenv Inspect and clear them before and after each job
Temporary files Overly broad permissions or files left after an abnormal exit Set permissions to 600 and delete with a trap
Logs Shell tracing prints command arguments Disable set -x while handling secrets

An audit script only needs to determine whether a specific marker is visible. Do not save environment snapshots as build artifacts. Complete output from env, export, or ps eww can itself become a new source of leakage.

Test Inheritance with a Random Marker

Never experiment with a real token. Generate a one-time marker, start a short-lived child process, and silently check whether it can see the marker. The test below does not print the marker to the log, but its exit status tells the pipeline whether inheritance occurred.

set -euo pipefail

marker="$(/usr/bin/uuidgen)"
export CI_SECRET_PROBE="$marker"

/bin/sleep 20 &
child_pid=$!

if /bin/ps eww -p "$child_pid" -o command= | /usr/bin/grep -q "$marker"; then
  result=1
else
  result=0
fi

kill "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
unset CI_SECRET_PROBE

test "$result" -eq 1

This test confirms that default inheritance exists. It does not mean that production logs should repeatedly scan every process. For formal validation, use a fixed variable name with a random value and record only “passed” or “failed.”

Check for launchd Residue

Do not inject job secrets with launchctl setenv, because their lifetime may extend beyond a single shell session. If legacy scripts have used this method, first inspect the state without printing the value, then run:

launchctl unsetenv CI_RELEASE_TOKEN
test -z "$(launchctl getenv CI_RELEASE_TOKEN)"

Perform this check once at job startup and again during cleanup. If the runner operates as a dedicated user, run it within that user’s launchd domain rather than checking only the current remote session.

Create a Minimal Build Environment

env -i can start a command with an empty environment, but the environment must not be cleared indiscriminately. The Xcode toolchain typically needs HOME, PATH, TMPDIR, locale settings, and an explicit developer directory. Validate the setup with a version query before running the full build.

set -euo pipefail

job_tmp="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-ci.XXXXXX")"
trap '/bin/rm -rf "$job_tmp"' EXIT HUP INT TERM

/usr/bin/env -i \
  HOME="$HOME" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  TMPDIR="$job_tmp/" \
  LANG="en_US.UTF-8" \
  DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" \
  /usr/bin/xcrun xcodebuild -version

If the project depends on Ruby, Node.js, or a package manager, add each required executable directory to PATH individually instead of restoring the entire login shell environment. For every variable added, document which command requires it.

Separate Build and Release into Two Permission Domains

The build stage should produce only archives and verification files, without access to release credentials. The release stage should read validated artifacts and inject only the variables required by the upload command. Do not run export CI_RELEASE_TOKEN at the beginning of the job, because every subsequent script will inherit it.

set +x
CI_RELEASE_TOKEN="$release_token" \
  /usr/bin/env \
  RELEASE_ARTIFACT="$artifact_path" \
  ./ci/publish.sh
unset release_token
set -x

A safer design is for publish.sh to start exactly one upload process, wait for it to exit, and then terminate immediately. If the tool requires a credentials file, use a job-specific temporary directory, set umask 077 before creating the file, and verify afterward that its permissions are 600. Never place the file in the repository, on the user’s desktop, or in a persistent cache directory.

Restrict Failure Handlers

Failure hooks often collect environment, process, and workspace information. They must not run an unfiltered env or package complete command lines. They may collect exit codes, tool versions, artifact paths, available disk space, and allowlisted variable names. When checking for secrets, compare only hashes or test whether a random probe appears; never retain the original value.

Make Cleanup Results a Job Gate

A successful job does not imply successful cleanup. The final stage should verify at minimum that release variables have been unset, the corresponding names are absent from launchd, temporary directories no longer exist, test and upload child processes have exited, and command tracing is disabled in the logs. If any check fails, mark the current runner for inspection and prevent it from accepting further release jobs that carry secrets.

Create a dedicated TMPDIR for every job and treat the process group as the cleanup unit. Killing only the main script may leave simulator helpers, upload tools, or custom daemons running. After cleanup, run another random probe with no real secret value and confirm that new child processes cannot observe the marker from the previous stage.

The goal is not to eliminate environment variables from the pipeline entirely, but to establish clear boundaries: the build stage has no release secrets, only one necessary process can see the secret during the release stage, and no process, launchd domain, or disk location retains it after the job ends. Once these checks are built into the runner’s initialization and teardown scripts, future projects no longer need to rely on operator memory.

Frequently asked questions

Are CI secrets safe if they exist only in environment variables?

No. Environment variables are inherited by child processes and may be exposed through diagnostics, debug output, or persistent services. Inject each secret only into the command that needs it.

How can I verify that a completed job left no secret variables behind?

Inspect the runner parent, launchd domain, and surviving child processes for variable names, then confirm that the job-specific temporary directory was removed. Never print the real secret value.

Can env -i break xcodebuild?

Yes, because it removes the default environment. Explicitly provide HOME, PATH, TMPDIR, LANG, and DEVELOPER_DIR, then validate the allowlist with a version check and a small build.

Dedicated physical nodes

Move your next build to a cloud Mac mini

Choose your configuration, billing cycle, and node region to run development, builds, and automation on a dedicated physical machine.

Rent now