Skip to content

Commit

Permalink
macOS prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
smoelius committed Apr 5, 2024
1 parent b94e3ac commit 82d223e
Show file tree
Hide file tree
Showing 12 changed files with 129 additions and 12 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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

Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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
17 changes: 17 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String> = args().collect();

Expand Down
71 changes: 65 additions & 6 deletions src/util/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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::<Result<Vec<_>>>()?;
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);
Expand All @@ -91,6 +94,62 @@ impl<T: AsRef<Path>> ToUtf8 for T {
}
}

fn split_escaped(mut s: &str) -> Result<Vec<String>> {
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<String> {
Expand Down
File renamed without changes.
1 change: 1 addition & 0 deletions tests/cases/outside_out_dir.macos.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
message: "Operation not permitted"
File renamed without changes.
1 change: 1 addition & 0 deletions tests/cases/ping.macos.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ping: sendto: Operation not permitted
File renamed without changes.
1 change: 1 addition & 0 deletions tests/cases/tiocsti.macos.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
libc::ioctl: Operation not permitted
7 changes: 6 additions & 1 deletion tests/custom_build_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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```",
);
}
9 changes: 8 additions & 1 deletion tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit 82d223e

Please sign in to comment.