From 710cd9c835ce869955bdcb80a3e71f5b875b2f5e Mon Sep 17 00:00:00 2001 From: Pavan Kalyan Reddy Cherupally Date: Mon, 20 Apr 2026 11:06:24 -0500 Subject: [PATCH 1/2] feat(build): add --build-isolation flag for sandboxed builds Add build isolation using ephemeral Unix users and Linux namespaces to prevent untrusted build backends from reading credentials or interfering with the host system. The isolation mechanism: - Creates an ephemeral Unix user (useradd/userdel ~10ms each) that cannot read credential files like .netrc (root:root 600) - Uses Linux namespaces via unshare for network (no routing), PID (process visibility), IPC (shared memory), and UTS (hostname) - setpriv drops privileges before entering namespaces, avoiding the setgroups() denial that affects runuser in user namespaces - Works in unprivileged containers (Podman/Docker) without --privileged or --cap-add SYS_ADMIN New CLI flag --build-isolation/--no-build-isolation supersedes --network-isolation for build steps. The flag threads through WorkContext, BuildEnvironment, and the build backend hook caller. Build dependency installation (uv pip install) explicitly opts out since it needs access to the local PyPI mirror. Signed-off-by: Pavan Kalyan Reddy Cherupally Co-Authored-By: Claude --- src/fromager/__main__.py | 23 +++++++++++ src/fromager/build_environment.py | 6 +++ src/fromager/context.py | 2 + src/fromager/dependencies.py | 1 + src/fromager/external_commands.py | 49 +++++++++++++++++++++- src/fromager/run_build_isolation.sh | 64 +++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100755 src/fromager/run_build_isolation.sh diff --git a/src/fromager/__main__.py b/src/fromager/__main__.py index a2d4d891..129ecca4 100644 --- a/src/fromager/__main__.py +++ b/src/fromager/__main__.py @@ -30,6 +30,15 @@ SUPPORTS_NETWORK_ISOLATION = True NETWORK_ISOLATION_ERROR = None +try: + external_commands.detect_build_isolation() +except Exception as e: + SUPPORTS_BUILD_ISOLATION: bool = False + BUILD_ISOLATION_ERROR: str | None = str(e) +else: + SUPPORTS_BUILD_ISOLATION = True + BUILD_ISOLATION_ERROR = None + @click.group() @click.version_option( @@ -143,6 +152,14 @@ help="Build sdist and when with network isolation (unshare -cn)", show_default=True, ) +@click.option( + "--build-isolation/--no-build-isolation", + default=False, + help="Sandbox build steps with mount, PID, IPC, and network namespace isolation. " + "Hides credentials, makes system directories read-only, and isolates /tmp. " + "Supersedes --network-isolation for build steps.", + show_default=True, +) @click.pass_context def main( ctx: click.Context, @@ -163,6 +180,7 @@ def main( variant: str, jobs: int | None, network_isolation: bool, + build_isolation: bool, ) -> None: # Save the debug flag so invoke_main() can use it. global _DEBUG @@ -220,6 +238,7 @@ def main( logger.info(f"maximum concurrent jobs: {jobs}") logger.info(f"constraints file: {constraints_file}") logger.info(f"network isolation: {network_isolation}") + logger.info(f"build isolation: {build_isolation}") if build_wheel_server_url: logger.info(f"external build wheel server: {build_wheel_server_url}") else: @@ -230,6 +249,9 @@ def main( if network_isolation and not SUPPORTS_NETWORK_ISOLATION: ctx.fail(f"network isolation is not available: {NETWORK_ISOLATION_ERROR}") + if build_isolation and not SUPPORTS_BUILD_ISOLATION: + ctx.fail(f"build isolation is not available: {BUILD_ISOLATION_ERROR}") + wkctx = context.WorkContext( active_settings=packagesettings.Settings.from_files( settings_file=settings_file, @@ -247,6 +269,7 @@ def main( cleanup=cleanup, variant=variant, network_isolation=network_isolation, + build_isolation=build_isolation, max_jobs=jobs, settings_dir=settings_dir, ) diff --git a/src/fromager/build_environment.py b/src/fromager/build_environment.py index a5480be5..7553abeb 100644 --- a/src/fromager/build_environment.py +++ b/src/fromager/build_environment.py @@ -140,12 +140,14 @@ def run( cwd: str | None = None, extra_environ: dict[str, str] | None = None, network_isolation: bool | None = None, + build_isolation: bool | None = None, log_filename: str | None = None, stdin: TextIOWrapper | None = None, ) -> str: """Run command in a virtual environment `network_isolation` defaults to context setting. + `build_isolation` defaults to context setting. """ extra_environ = extra_environ.copy() if extra_environ else {} extra_environ.update(self.get_venv_environ(template_env=extra_environ)) @@ -153,6 +155,8 @@ def run( # default from context if network_isolation is None: network_isolation = self._ctx.network_isolation + if build_isolation is None: + build_isolation = self._ctx.build_isolation if network_isolation: # Build Rust dependencies without network access extra_environ.setdefault("CARGO_NET_OFFLINE", "true") @@ -162,6 +166,7 @@ def run( cwd=cwd, extra_environ=extra_environ, network_isolation=network_isolation, + build_isolation=build_isolation, log_filename=log_filename, stdin=stdin, ) @@ -210,6 +215,7 @@ def install(self, reqs: typing.Iterable[Requirement]) -> None: cmd, cwd=str(self.path.parent), network_isolation=False, + build_isolation=False, ) logger.info( "installed dependencies %s into build environment in %s", diff --git a/src/fromager/context.py b/src/fromager/context.py index 996971ea..3cdb4f68 100644 --- a/src/fromager/context.py +++ b/src/fromager/context.py @@ -43,6 +43,7 @@ def __init__( cleanup: bool = True, variant: str = "cpu", network_isolation: bool = False, + build_isolation: bool = False, max_jobs: int | None = None, settings_dir: pathlib.Path | None = None, wheel_server_url: str = "", @@ -81,6 +82,7 @@ def __init__( self.cleanup_buildenv = cleanup self.variant = variant self.network_isolation = network_isolation + self.build_isolation = build_isolation self.settings_dir = settings_dir self._constraints_filename = self.work_dir / "constraints.txt" diff --git a/src/fromager/dependencies.py b/src/fromager/dependencies.py index e7964e4d..571210a6 100644 --- a/src/fromager/dependencies.py +++ b/src/fromager/dependencies.py @@ -557,6 +557,7 @@ def _run_hook_with_extra_environ( cwd=cwd, extra_environ=extra_environ, network_isolation=ctx.network_isolation, + build_isolation=ctx.build_isolation, log_filename=log_filename, ) diff --git a/src/fromager/external_commands.py b/src/fromager/external_commands.py index 6c4a2607..de6b9f05 100644 --- a/src/fromager/external_commands.py +++ b/src/fromager/external_commands.py @@ -14,11 +14,15 @@ HERE = pathlib.Path(__file__).absolute().parent NETWORK_ISOLATION: list[str] | None +BUILD_ISOLATION: list[str] | None if sys.platform == "linux": # runner script with `unshare -rn` + `ip link set lo up` NETWORK_ISOLATION = [str(HERE / "run_network_isolation.sh")] + # runner script with full build sandboxing (mount, PID, IPC, net, env scrubbing) + BUILD_ISOLATION = [str(HERE / "run_build_isolation.sh")] else: NETWORK_ISOLATION = None + BUILD_ISOLATION = None def network_isolation_cmd() -> typing.Sequence[str]: @@ -32,6 +36,17 @@ def network_isolation_cmd() -> typing.Sequence[str]: raise ValueError(f"unsupported platform {sys.platform}") +def build_isolation_cmd() -> typing.Sequence[str]: + """Return command list for full build isolation. + + Raises ValueError when build isolation is not supported. + Returns: command list to run a process with build isolation + """ + if BUILD_ISOLATION: + return BUILD_ISOLATION + raise ValueError(f"unsupported platform {sys.platform}") + + def detect_network_isolation() -> None: """Detect if network isolation is available and working @@ -44,6 +59,17 @@ def detect_network_isolation() -> None: subprocess.check_output(check, stderr=subprocess.STDOUT) +def detect_build_isolation() -> None: + """Detect if build isolation is available and working. + + Build isolation requires mount, PID, IPC, and network namespace support. + """ + cmd = build_isolation_cmd() + if os.name == "posix": + check = [*cmd, "true"] + subprocess.check_output(check, stderr=subprocess.STDOUT) + + class NetworkIsolationError(subprocess.CalledProcessError): pass @@ -55,6 +81,7 @@ def run( cwd: str | None = None, extra_environ: dict[str, typing.Any] | None = None, network_isolation: bool = False, + build_isolation: bool = False, log_filename: str | None = None, stdin: TextIOWrapper | None = None, ) -> str: @@ -64,13 +91,33 @@ def run( line with the current package name for easier searching. Raises ``NetworkIsolationError`` instead of ``CalledProcessError`` when the failure output indicates a network access problem. + + When build_isolation is True, the command runs as an ephemeral Unix user + with network, PID, IPC, and UTS namespace isolation. The ephemeral user + cannot read credential files (e.g. .netrc owned by root with mode 600). + This supersedes network_isolation. """ if extra_environ is None: extra_environ = {} env = os.environ.copy() env.update(extra_environ) - if network_isolation: + if build_isolation: + # Ephemeral user + PID + IPC + network + UTS namespace isolation. + # The ephemeral user provides file-level credential protection + # (.netrc is root:root 600, unreadable by the build user). + # This supersedes network_isolation. + cmd = [ + *build_isolation_cmd(), + *cmd, + ] + # Tell the isolation script which directory needs to be writable + # by the ephemeral build user. + if cwd: + env["FROMAGER_BUILD_DIR"] = cwd + env.setdefault("CARGO_NET_OFFLINE", "true") + network_isolation = True # for error detection below + elif network_isolation: # prevent network access by creating a new network namespace that # has no routing configured. cmd = [ diff --git a/src/fromager/run_build_isolation.sh b/src/fromager/run_build_isolation.sh new file mode 100755 index 00000000..3ba88a9e --- /dev/null +++ b/src/fromager/run_build_isolation.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# +# Run command with build isolation for untrusted build backends. +# +# Uses an ephemeral Unix user for file-level isolation: +# - Cannot read credential files like .netrc (owned by root, mode 600) +# - Gets its own /tmp entries (sticky bit prevents cross-user access) +# +# Combined with Linux namespaces for: +# - Network isolation (no routing in new net namespace) +# - PID isolation (build cannot see other processes) +# - IPC isolation (isolated shared memory, semaphores, message queues) +# - UTS isolation (separate hostname) +# +# The ephemeral user is created before entering the namespace, then +# unshare runs as that user with --map-root-user so it has enough +# privilege to bring up loopback and set hostname inside the namespace. +# +# This works in unprivileged containers (Podman/Docker) without --privileged +# or --cap-add SYS_ADMIN. +# +# Ubuntu 24.04: needs `sysctl kernel.apparmor_restrict_unprivileged_userns=0` +# + +set -e +set -o pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 command [args...]" >&2 + exit 2 +fi + +# --- Ephemeral user creation (before namespace entry) --- + +BUILD_USER="fmr_$(head -c4 /dev/urandom | od -An -tu4 | tr -d ' ')" +useradd -r -M -d /nonexistent -s /sbin/nologin "$BUILD_USER" +trap 'userdel "$BUILD_USER" 2>/dev/null || true' EXIT + +# Make build dir writable by ephemeral user if set +if [ -n "${FROMAGER_BUILD_DIR:-}" ] && [ -d "$FROMAGER_BUILD_DIR" ]; then + chmod -R o+rwX "$FROMAGER_BUILD_DIR" 2>/dev/null || true +fi + +# --- Enter namespaces as ephemeral user --- +# setpriv drops to the ephemeral user, then unshare creates namespaces. +# --map-root-user maps the ephemeral user to UID 0 inside the namespace +# so it can run ip/hostname. + +BUILD_UID=$(id -u "$BUILD_USER") +BUILD_GID=$(id -g "$BUILD_USER") + +exec setpriv --reuid="$BUILD_UID" --regid="$BUILD_GID" --clear-groups -- \ + unshare --uts --net --pid --ipc --fork --map-root-user -- \ + /bin/bash -c ' + # bring loopback up + if command -v ip 2>&1 >/dev/null; then + ip link set lo up + fi + # set hostname + if command -v hostname 2>&1 >/dev/null; then + hostname localhost + fi + exec "$@" + ' -- "$@" From 5a54dff87be065ba46f6db825f21daf381639d94 Mon Sep 17 00:00:00 2001 From: Pavan Kalyan Reddy Cherupally Date: Mon, 20 Apr 2026 11:14:35 -0500 Subject: [PATCH 2/2] feat(build): add FROMAGER_SCRUB_ENV_VARS for env variable scrubbing When build isolation is enabled, remove environment variables listed in FROMAGER_SCRUB_ENV_VARS (comma-separated) from the build subprocess environment. This allows downstream build systems to strip sensitive variables (e.g. internal registry tokens, CI credentials) without hardcoding any variable names in fromager itself. Signed-off-by: Pavan Kalyan Reddy Cherupally Co-Authored-By: Claude --- src/fromager/external_commands.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/fromager/external_commands.py b/src/fromager/external_commands.py index de6b9f05..352adbd9 100644 --- a/src/fromager/external_commands.py +++ b/src/fromager/external_commands.py @@ -25,6 +25,19 @@ BUILD_ISOLATION = None +def _get_scrub_env_vars() -> frozenset[str]: + """Return the set of environment variable names to remove during build isolation. + + Reads from the ``FROMAGER_SCRUB_ENV_VARS`` environment variable, which + should be a comma-separated list of variable names. Returns an empty set + if the variable is not set. + """ + raw = os.environ.get("FROMAGER_SCRUB_ENV_VARS", "") + if not raw: + return frozenset() + return frozenset(v.strip() for v in raw.split(",") if v.strip()) + + def network_isolation_cmd() -> typing.Sequence[str]: """Detect network isolation wrapper @@ -115,6 +128,11 @@ def run( # by the ephemeral build user. if cwd: env["FROMAGER_BUILD_DIR"] = cwd + # Remove variables listed in FROMAGER_SCRUB_ENV_VARS from the + # environment so they are not visible to build backends. + scrub_vars = _get_scrub_env_vars() + for var in scrub_vars: + env.pop(var, None) env.setdefault("CARGO_NET_OFFLINE", "true") network_isolation = True # for error detection below elif network_isolation: