From 82d223e5ae7c29308028a53b99887cc4243183df Mon Sep 17 00:00:00 2001 From: Samuel Moelius Date: Thu, 4 Apr 2024 07:54:50 -0400 Subject: [PATCH] macOS prototype --- .github/workflows/ci.yml | 7 +- README.md | 27 ++++++- src/main.rs | 17 +++++ src/util/common.rs | 71 +++++++++++++++++-- ...ir.stderr => outside_out_dir.linux.stderr} | 0 tests/cases/outside_out_dir.macos.stderr | 1 + .../cases/{ping.stderr => ping.linux.stderr} | 0 tests/cases/ping.macos.stderr | 1 + .../{tiocsti.stderr => tiocsti.linux.stderr} | 0 tests/cases/tiocsti.macos.stderr | 1 + tests/custom_build_name.rs | 7 +- tests/integration.rs | 9 ++- 12 files changed, 129 insertions(+), 12 deletions(-) rename tests/cases/{outside_out_dir.stderr => outside_out_dir.linux.stderr} (100%) create mode 100644 tests/cases/outside_out_dir.macos.stderr rename tests/cases/{ping.stderr => ping.linux.stderr} (100%) create mode 100644 tests/cases/ping.macos.stderr rename tests/cases/{tiocsti.stderr => tiocsti.linux.stderr} (100%) create mode 100644 tests/cases/tiocsti.macos.stderr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b9130e..53337b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: ${{ github.event_name == 'merge_group' }} matrix: - environment: [ubuntu-latest] + environment: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.environment }} @@ -71,11 +71,14 @@ jobs: - name: Install tools run: | - sudo apt install bubblewrap rustup install nightly rustup +nightly component add clippy cargo install group-runner || true + - name: Install Bubblewrap + if: ${{ matrix.environment == 'ubuntu-latest' }} + run: sudo apt install bubblewrap + - name: Build run: cargo test --no-run diff --git a/README.md b/README.md index 1c3af37..1b16f1d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A linker replacement to help protect against malicious build scripts -`build-wrap` "re-links" a build script so that it is executed under another command. By default the command is [Bubblewrap], though this is configurable. See [Environment variables] and [How it works] for more information. +`build-wrap` "re-links" a build script so that it is executed under another command. By default, the command is [Bubblewrap] (Linux) or [`sandbox-exec`] (macOS), though this is configurable. See [Environment variables] and [How it works] for more information. ## Installation @@ -20,7 +20,7 @@ Installing `build-wrap` requires two steps: ## Environment variables -- `BUILD_WRAP_CMD`: Command used to execute a build script. Default: +- `BUILD_WRAP_CMD`: Command used to execute a build script. Linux default: ```sh bwrap @@ -34,6 +34,25 @@ Installing `build-wrap` requires two steps: Note that `bwrap` is [Bubblewrap]. + macOS default: + + ```sh + sandbox-exec -p + (version\ 1)\ + (deny\ default)\ + (allow\ file-read*)\ # Allow read-only access everywhere + (allow\ file-write*\ (subpath\ "/dev"))\ # Allow write access to /dev + (allow\ file-write*\ (subpath\ "{OUT_DIR}"))\ # Allow write access to `OUT_DIR` + (allow\ file-write*\ (subpath\ "{TMPDIR}"))\ # Allow write access to `TMPDIR` + (allow\ process-exec)\ # Allow `exec` + (allow\ process-fork)\ # Allow `fork` + (allow\ sysctl-read)\ # Allow reading kernel state + (deny\ network*) # Deny network access + {} # Build script path + ``` + + Note that `(version\ 1)\ ... (deny\ network*)` expands to a single string (see [How `BUILD_WRAP_CMD` is expanded] below). + - `BUILD_WRAP_LD`: Linker to use. Default: `cc` Note that the above environment variables are read **when the build script is linked**. So, for example, changing `BUILD_WRAP_CMD` will not change the command used to execute already linked build scripts. @@ -44,6 +63,8 @@ Note that the above environment variables are read **when the build script is li - `{VAR}` is replaced with the value of environment variable `VAR`. - `{{` is replaced with `{`. - `}}` is replaced with `}`. +- `\` followed by a whitespace character is replaced with that whitespace character. +- `\\` is replaced with `\`. ## How `build-wrap` works @@ -63,4 +84,6 @@ Given a build script `B`, its "wrapped" version `B'` contains a copy of `B` and [Bubblewrap]: https://github.com/containers/bubblewrap [Environment variables]: #environment-variables [How it works]: #how-it-works +[How `BUILD_WRAP_CMD` is expanded]: #how-build_wrap_cmd-is-expanded +[`sandbox-exec`]: https://keith.github.io/xcode-man-pages/sandbox-exec.1.html [manner described above]: #how-build_wrap_cmd-is-expanded diff --git a/src/main.rs b/src/main.rs index a324035..6f45b6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use std::{ mod util; mod wrapper; +#[cfg(target_os = "linux")] const DEFAULT_CMD: &str = "bwrap --ro-bind / / --dev-bind /dev /dev @@ -18,6 +19,22 @@ const DEFAULT_CMD: &str = "bwrap --unshare-net {}"; +// smoelius: The following blog post is a useful `sandbox-exec` reference: +// https://7402.org/blog/2020/macos-sandboxing-of-folder.html +#[cfg(target_os = "macos")] +const DEFAULT_CMD: &str = r#"sandbox-exec -p +(version\ 1)\ +(deny\ default)\ +(allow\ file-read*)\ +(allow\ file-write*\ (subpath\ "/dev"))\ +(allow\ file-write*\ (subpath\ "{OUT_DIR}"))\ +(allow\ file-write*\ (subpath\ "{TMPDIR}"))\ +(allow\ process-exec)\ +(allow\ process-fork)\ +(allow\ sysctl-read)\ +(deny\ network*) +{}"#; + fn main() -> Result<()> { let args: Vec = args().collect(); diff --git a/src/util/common.rs b/src/util/common.rs index f23b70b..5ca951f 100644 --- a/src/util/common.rs +++ b/src/util/common.rs @@ -61,16 +61,19 @@ fn unpack_and_exec(bytes: &[u8]) -> Result<()> { // They will cause the wrapped build script to be rerun, however. let cmd = option_env!("BUILD_WRAP_CMD").ok_or_else(|| anyhow!("`BUILD_WRAP_CMD` is undefined"))?; - let expanded_cmd = __expand_cmd(cmd, &temp_path)?; - let args = expanded_cmd.split_ascii_whitespace().collect::>(); - eprintln!("expanded `BUILD_WRAP_CMD`: {:#?}", &args); + let args = split_escaped(cmd)?; + let expanded_args = args + .into_iter() + .map(|arg| __expand_cmd(&arg, &temp_path)) + .collect::>>()?; + eprintln!("expanded `BUILD_WRAP_CMD`: {:#?}", &expanded_args); ensure!( - !args.is_empty(), + !expanded_args.is_empty(), "expanded `BUILD_WRAP_CMD` is empty or all whitespace" ); - let mut command = Command::new(args[0]); - command.args(&args[1..]); + let mut command = Command::new(&expanded_args[0]); + command.args(&expanded_args[1..]); let _: Output = exec(command, true)?; drop(temp_path); @@ -91,6 +94,62 @@ impl> ToUtf8 for T { } } +fn split_escaped(mut s: &str) -> Result> { + let mut v = vec![String::new()]; + + while let Some(i) = s.find(|c: char| c.is_ascii_whitespace() || c == '\\') { + debug_assert!(!v.is_empty()); + // smoelius: Only the last string in `v` can be empty. + debug_assert!(v + .iter() + .position(String::is_empty) + .map_or(true, |i| i == v.len() - 1)); + + let c = s.as_bytes()[i]; + + v.last_mut().unwrap().push_str(&s[..i]); + + s = &s[i + 1..]; + + // smoelius: `i` shouldn't be needed anymore. + #[allow(unused_variables, clippy::let_unit_value)] + let i = (); + + if c.is_ascii_whitespace() { + if !v.last().unwrap().is_empty() { + v.push(String::new()); + } + continue; + } + + // smoelius: If the previous `if` fails, then `c` must be a backslash. + if !s.is_empty() { + let c = s.as_bytes()[0]; + // smoelius: Verify that `c` is a legally escapable character before subslicing `s`. + ensure!( + c.is_ascii_whitespace() || c == b'\\', + "illegally escaped character" + ); + s = &s[1..]; + v.last_mut().unwrap().push(c as char); + continue; + } + + bail!("trailing backslash"); + } + + // smoelius: Push whatever is left. + v.last_mut().unwrap().push_str(s); + + if v.last().unwrap().is_empty() { + v.pop(); + } + + debug_assert!(!v.iter().any(String::is_empty)); + + Ok(v) +} + // smoelius: `__expand_cmd` is `pub` simply to allow testing it in an integration test. It is not // meant to be used outside of this module. pub fn __expand_cmd(mut cmd: &str, build_script_path: &Path) -> Result { diff --git a/tests/cases/outside_out_dir.stderr b/tests/cases/outside_out_dir.linux.stderr similarity index 100% rename from tests/cases/outside_out_dir.stderr rename to tests/cases/outside_out_dir.linux.stderr diff --git a/tests/cases/outside_out_dir.macos.stderr b/tests/cases/outside_out_dir.macos.stderr new file mode 100644 index 0000000..a2a9fa5 --- /dev/null +++ b/tests/cases/outside_out_dir.macos.stderr @@ -0,0 +1 @@ +message: "Operation not permitted" diff --git a/tests/cases/ping.stderr b/tests/cases/ping.linux.stderr similarity index 100% rename from tests/cases/ping.stderr rename to tests/cases/ping.linux.stderr diff --git a/tests/cases/ping.macos.stderr b/tests/cases/ping.macos.stderr new file mode 100644 index 0000000..b06ee12 --- /dev/null +++ b/tests/cases/ping.macos.stderr @@ -0,0 +1 @@ +ping: sendto: Operation not permitted diff --git a/tests/cases/tiocsti.stderr b/tests/cases/tiocsti.linux.stderr similarity index 100% rename from tests/cases/tiocsti.stderr rename to tests/cases/tiocsti.linux.stderr diff --git a/tests/cases/tiocsti.macos.stderr b/tests/cases/tiocsti.macos.stderr new file mode 100644 index 0000000..e5f6235 --- /dev/null +++ b/tests/cases/tiocsti.macos.stderr @@ -0,0 +1 @@ +libc::ioctl: Operation not permitted diff --git a/tests/custom_build_name.rs b/tests/custom_build_name.rs index f1dd449..f2d7c5f 100644 --- a/tests/custom_build_name.rs +++ b/tests/custom_build_name.rs @@ -20,8 +20,13 @@ fn custom_build_name() { assert!(!output.status.success()); let stderr = std::str::from_utf8(&output.stderr).unwrap(); + let syscall = if cfg!(target_os = "linux") { + "socket" + } else { + "sendto" + }; assert!( - stderr.contains("ping: socket: Operation not permitted"), + stderr.contains(&format!("ping: {syscall}: Operation not permitted")), "stderr does not contain expected string:\n```\n{stderr}\n```", ); } diff --git a/tests/integration.rs b/tests/integration.rs index 3362396..01d0e71 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -36,7 +36,14 @@ fn integration() { } fn test_case(path: &Path) { - let stderr_path = path.with_extension("stderr"); + let mut stderr_path = path.with_extension("stderr"); + if !stderr_path.exists() { + stderr_path = if cfg!(target_os = "linux") { + path.with_extension("linux.stderr") + } else { + path.with_extension("macos.stderr") + } + } let expected_stderr_substring = read_to_string(stderr_path).unwrap(); let temp_package = util::temp_package(path).unwrap();