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..352adbd9 100644 --- a/src/fromager/external_commands.py +++ b/src/fromager/external_commands.py @@ -14,11 +14,28 @@ 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 _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]: @@ -32,6 +49,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 +72,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 +94,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 +104,38 @@ 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 + # 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: # 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 "$@" + ' -- "$@"