blob: a79b091f91b0e6522f96145a366aad8db665c6f1 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#![cfg(unix)]
use maka_agent_facts::tools::ToolExecutor;
use maka_process::{SHELL_NAME, ShellExecutor};
use maka_sandbox::{
Network, Sandbox,
filesystem::{Access, Policy, Rule},
};
use serde_json::json;
use tokio_util::sync::CancellationToken;
#[cfg(target_os = "macos")]
#[tokio::test]
async fn destination_grants_route_native_pipes_and_ptys_without_allowing_direct_connections() {
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
tokio::time::timeout(std::time::Duration::from_secs(15), async {
let root = tempfile::tempdir().unwrap();
let origin = TcpListener::bind("127.0.0.1:0").await.unwrap();
let denied = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = origin.local_addr().unwrap();
let denied_address = denied.local_addr().unwrap();
let server = tokio::spawn(async move {
for _ in 0..2 {
let (mut socket, _) = origin.accept().await.unwrap();
let mut headers = Vec::new();
while !headers.ends_with(b"\r\n\r\n") {
headers.push(socket.read_u8().await.unwrap());
}
socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK",
)
.await
.unwrap();
}
});
let sandbox = Sandbox::Managed {
filesystem: Policy {
default: Access::Read,
rules: vec![],
deny_globs: vec![],
},
network: Network::destination(
maka_sandbox::Destination::new("127.0.0.1", address.port()).unwrap(),
),
};
let executor = ShellExecutor::new(root.path(), sandbox).unwrap();
let command = format!(
r#"
/usr/bin/curl -fsS --max-time 2 http://{address} || exit 10
if /usr/bin/curl -fsS --max-time 2 http://{denied_address}; then exit 11; fi
if /usr/bin/curl --noproxy '*' -fsS --max-time 2 http://{address}; then exit 12; fi
"#
);
let output = executor
.invoke(
SHELL_NAME.into(),
json!({"command":command}),
CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(output["status"], "completed", "{output}");
assert_eq!(output["exitCode"], 0, "{output}");
assert_eq!(output["output"]["stdout"], "OK", "{output}");
let (mut child, terminal) = maka_process::pty::spawn(
executor.command_pty(&command).unwrap(),
maka_agent_facts::terminal::TerminalSize::new(80, 24).unwrap(),
)
.await
.unwrap();
let mut output = Vec::new();
let (status, ()) = tokio::join!(child.wait(), async {
let mut buffer = [0; 4096];
loop {
let count = terminal.read(&mut buffer).await.unwrap();
if count == 0 {
break;
}
output.extend_from_slice(&buffer[..count]);
}
});
assert!(
status.unwrap().success(),
"{}",
String::from_utf8_lossy(&output)
);
child.close().await.unwrap();
server.await.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(30), denied.accept())
.await
.is_err()
);
})
.await
.unwrap();
}
#[tokio::test]
async fn loader_configuration_cannot_execute_before_the_sandbox_boundary() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let work = root.join("work");
let outside = root.join("outside");
std::fs::create_dir(&work).unwrap();
std::fs::create_dir(&outside).unwrap();
let sandbox = Sandbox::Managed {
filesystem: Policy {
default: Access::Read,
rules: vec![
Rule::subtree(&work, Access::Write),
Rule::subtree(work.join(".git"), Access::Read),
],
deny_globs: Vec::new(),
},
network: Network::Denied,
};
let command = || {
let mut command = maka_process::Command::new("/bin/sh", &work)
.sandbox(&sandbox)
.unwrap();
// Overrides after capture must be checked too, before allocating guards.
command
.args(["-c", "touch unexpected"])
.env("LD_DEBUG", "libs")
.env("LD_DEBUG_OUTPUT", outside.join("loader"));
command
};
let failure = match maka_process::pipe::spawn(command()).await {
Ok(_) => panic!("pipe accepted loader control before isolation"),
Err(error) => error,
};
assert!(
failure.to_string().contains("inside the sandboxed command"),
"{failure}"
);
let failure = match maka_process::pty::spawn(
command(),
maka_agent_facts::terminal::TerminalSize::new(80, 24).unwrap(),
)
.await
{
Ok(_) => panic!("PTY accepted loader control before isolation"),
Err(error) => error,
};
assert!(
failure.to_string().contains("inside the sandboxed command"),
"{failure}"
);
assert_eq!(std::fs::read_dir(&work).unwrap().count(), 0);
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
#[cfg(target_os = "linux")]
for target in [&outside, &work] {
// Loader diagnostics are a real pre-main filesystem effect: permitted
// inside work, denied outside it. Normal target env remains available.
let mut command = maka_process::Command::new("/usr/bin/env", &work);
command.env("MAKA_ALLOWED", "yes").args([
"LD_DEBUG=libs".into(),
format!("LD_DEBUG_OUTPUT={}", target.join("loader").display()),
"/bin/sh".into(),
"-c".into(),
"test \"$MAKA_ALLOWED\" = yes".into(),
]);
let maka_process::pipe::Spawned {
mut child,
stdin,
mut stdout,
mut stderr,
} = maka_process::pipe::spawn(command.sandbox(&sandbox).unwrap())
.await
.unwrap();
drop(stdin);
use tokio::io::AsyncReadExt;
let mut output = Vec::new();
let mut errors = Vec::new();
let (status, _, _) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(
child.wait(),
stdout.read_to_end(&mut output),
stderr.read_to_end(&mut errors)
)
})
.await
.unwrap();
assert!(
status.unwrap().success(),
"{}",
String::from_utf8_lossy(&errors)
);
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
}
#[cfg(target_os = "linux")]
assert!(std::fs::read_dir(&work).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.as_encoded_bytes()
.starts_with(b"loader.")
}));
}
#[tokio::test]
async fn foreground_and_observed_shells_share_nested_filesystem_boundaries() {
for default in [
Access::Read,
#[cfg(target_os = "linux")]
Access::Deny,
] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("work/protected")).unwrap();
std::fs::create_dir_all(root.join("work/protected/reopened")).unwrap();
std::fs::write(root.join("outside"), "unchanged").unwrap();
std::fs::write(root.join("work/protected/secret"), "secret").unwrap();
let mut rules = vec![
Rule::subtree(root.join("work"), Access::Write),
Rule::subtree(root.join("work/protected"), Access::Deny),
Rule::subtree(root.join("work/protected/reopened"), Access::Write),
Rule::subtree(root.join("work/.git"), Access::Read),
];
if default == Access::Deny {
rules.extend(["/usr", "/etc"].map(|path| Rule::subtree(path, Access::Read)));
}
let executor = ShellExecutor::new(
root.join("work"),
Sandbox::Managed {
filesystem: Policy {
default,
rules,
deny_globs: Vec::new(),
},
network: Network::Denied,
},
)
.unwrap();
let command = r#"
printf permitted > allowed || exit 10
printf reopened > protected/reopened/allowed || exit 11
if printf escaped > ../outside; then exit 12; fi
if cat protected/secret; then exit 13; fi
if mv protected moved; then exit 14; fi
if ln -s ../outside alias && printf escaped > alias; then exit 15; fi
if mkdir -p .git && printf escaped > .git/config; then exit 16; fi
rm -f alias
printf completed
"#;
let foreground = executor
.invoke(
SHELL_NAME.into(),
json!({"command":command}),
CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(foreground["status"], "completed", "{foreground}");
assert_eq!(foreground["output"]["stdout"], "completed");
assert!(!root.join("work/.git").exists());
let rejected = executor
.command_pipes("printf unexpected > rejected-effect")
.unwrap();
assert!(!root.join("work/.git").exists());
drop(rejected);
assert!(!root.join("work/.git").exists());
assert!(!root.join("work/rejected-effect").exists());
let prepared = executor
.command_pipes("printf unexpected > cancelled-effect")
.unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(
prepared
.observe(Some(5000), cancelled)
.unwrap()
.completion
.await
.is_err()
);
assert!(!root.join("work/.git").exists());
assert!(!root.join("work/cancelled-effect").exists());
let mut observed = executor
.observe(command.into(), Some(5000), CancellationToken::new())
.unwrap();
let (result, started) = tokio::join!(observed.completion, async {
let mut started = false;
while let Some(event) = observed.events.recv().await {
if matches!(event, maka_process::PipeEvent::Started) {
started = true;
}
}
started
});
assert!(started);
assert!(!root.join("work/.git").exists());
let result = result.unwrap();
assert!(
matches!(result.outcome, maka_process::ProcessOutcome::Exited(status) if status.success()),
"{result:?}"
);
assert_eq!(
std::fs::read_to_string(root.join("outside")).unwrap(),
"unchanged"
);
assert_eq!(
std::fs::read_to_string(root.join("work/protected/secret")).unwrap(),
"secret"
);
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn network_policy_blocks_host_sockets_without_breaking_anonymous_ipc() {
use std::os::unix::net::UnixDatagram;
let temp = tempfile::tempdir().unwrap();
let listener = UnixDatagram::bind(temp.path().join("host.sock")).unwrap();
listener.set_nonblocking(true).unwrap();
let tcp = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
for network in [Network::Denied, Network::Allowed] {
let source = r#"
import socket, sys, ctypes, errno, platform
if platform.machine() == 'x86_64':
libc = ctypes.CDLL(None, use_errno=True)
for number in [512, 547, 0x40000029]:
assert libc.syscall(number, socket.AF_INET, socket.SOCK_STREAM, 0) == -1
assert ctypes.get_errno() == errno.ENOSYS
for factory in [
lambda: socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM),
lambda: socket.socketpair(socket.AF_UNIX, socket.SOCK_DGRAM)[0],
lambda: socket.socketpair(socket.AF_UNIX, socket.SOCK_RAW)[0],
]:
try:
sender = factory()
sender.sendmsg([b'escaped'], [], 0, 'host.sock')
except PermissionError:
pass
else:
raise AssertionError('Host datagram socket was reachable')
a, b = socket.socketpair()
a.sendmsg([b'ipc'])
assert b.recv(3) == b'ipc'
a.send(b'normal')
assert b.recv(6) == b'normal'
try:
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.settimeout(1)
client.connect(('127.0.0.1', int(sys.argv[1])))
except PermissionError:
assert sys.argv[2] == 'denied'
else:
assert sys.argv[2] == 'allowed'
"#;
let mut command = maka_process::Command::new("/usr/bin/python3", temp.path());
command.env_clear().env("PATH", "/usr/bin:/bin").args([
"-c",
source,
&tcp.local_addr().unwrap().port().to_string(),
if network == Network::Allowed {
"allowed"
} else {
"denied"
},
]);
let command = command
.sandbox(&Sandbox::Managed {
filesystem: Policy::uniform(Access::Read),
network,
})
.unwrap();
let spawned = maka_process::pipe::spawn(command).await.unwrap();
let maka_process::pipe::Spawned {
mut child,
mut stdout,
mut stderr,
stdin,
} = spawned;
drop(stdin);
use tokio::io::AsyncReadExt;
let mut output = Vec::new();
let mut errors = Vec::new();
let (status, _, _) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(
child.wait(),
stdout.read_to_end(&mut output),
stderr.read_to_end(&mut errors)
)
})
.await
.unwrap();
assert!(
status.unwrap().success(),
"{}",
String::from_utf8_lossy(&errors)
);
assert_eq!(
listener.recv(&mut [0; 32]).unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
}