Build deterministic OCI sandbox specifications
This commit is contained in:
parent
bf8a3de567
commit
cf5948e8be
1 changed files with 1422 additions and 0 deletions
433
crates/lazaret-runtime-supervisor/tests/sandbox_builder.rs
Normal file
433
crates/lazaret-runtime-supervisor/tests/sandbox_builder.rs
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lazaret_core::configuration::{
|
||||
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
|
||||
};
|
||||
use lazaret_core::identity::{ContainerId, DeviceId, InodeId, NamespaceIdentity};
|
||||
use lazaret_runtime_supervisor::sandbox::{
|
||||
CREDENTIALS_DESTINATION, ESCALATION_CLIENT_DESTINATION, HostTerminal,
|
||||
MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, RootProcess,
|
||||
SandboxBuildErrorKind, SandboxBuildRequest, TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION,
|
||||
build_sandbox_bundle, production_sandbox_layout,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct Fixture {
|
||||
tree: TempDir,
|
||||
configuration: ResolvedDomainConfiguration,
|
||||
terminfo_source: PathBuf,
|
||||
terminfo_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
let configuration_directory = tree.path().join("configuration");
|
||||
let workspace = tree.path().join("workspace");
|
||||
let runtime = tree.path().join("runtime");
|
||||
fs::create_dir(&configuration_directory).unwrap();
|
||||
fs::create_dir(&workspace).unwrap();
|
||||
fs::create_dir(&runtime).unwrap();
|
||||
let configuration_path = configuration_directory.join("lazaret.toml");
|
||||
fs::write(
|
||||
&configuration_path,
|
||||
format!(
|
||||
r#"schema = "lazaret-trusted-host-config-v1"
|
||||
|
||||
[storage]
|
||||
data_root = "{}"
|
||||
|
||||
[profiles.default]
|
||||
image = "example.invalid/agent:v1"
|
||||
persistent_dirs = ["/root"]
|
||||
env = {{ SELECTED_SECRET = "env:SELECTED_HOST_SECRET" }}
|
||||
volumes = [
|
||||
{{ host = "{{profile_dir}}/read-only", container = "/root/read-only", kind = "directory", access = "read-only", create = true }},
|
||||
{{ host = "{{profile_dir}}/read-write", container = "/root/read-write", kind = "directory", access = "read-write", create = true }},
|
||||
]
|
||||
|
||||
[profiles.unselected]
|
||||
image = "example.invalid/unselected:v1"
|
||||
env = {{ UNSELECTED_SECRET = "env:UNSELECTED_HOST_SECRET" }}
|
||||
"#,
|
||||
tree.path().join("data").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let environment = BTreeMap::from([
|
||||
("SELECTED_HOST_SECRET".to_owned(), "selected-value".into()),
|
||||
("UNSELECTED_HOST_SECRET".to_owned(), "must-not-enter".into()),
|
||||
]);
|
||||
let configuration = load_trusted_configuration(ConfigurationLoadRequest {
|
||||
explicit_configuration_path: Some(configuration_path),
|
||||
selected_profile: "default".to_owned(),
|
||||
workspace,
|
||||
home: tree.path().join("home"),
|
||||
xdg_config_home: None,
|
||||
xdg_data_home: None,
|
||||
xdg_runtime_dir: runtime,
|
||||
environment,
|
||||
sandbox_layout: production_sandbox_layout().unwrap(),
|
||||
})
|
||||
.unwrap();
|
||||
let terminfo_root = tree.path().join("trusted-terminfo");
|
||||
let terminfo_source = terminfo_root.join("x").join("xterm-lazaret");
|
||||
fs::create_dir_all(terminfo_source.parent().unwrap()).unwrap();
|
||||
fs::write(&terminfo_source, b"compiled host terminfo capabilities").unwrap();
|
||||
Self {
|
||||
tree,
|
||||
configuration,
|
||||
terminfo_source,
|
||||
terminfo_root,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal(&self) -> HostTerminal {
|
||||
HostTerminal::from_trusted_roots(
|
||||
"xterm-lazaret",
|
||||
Some("truecolor".to_owned()),
|
||||
&self.terminfo_source,
|
||||
std::slice::from_ref(&self.terminfo_root),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn request(&self, root_process: RootProcess) -> SandboxBuildRequest<'_> {
|
||||
SandboxBuildRequest {
|
||||
configuration: &self.configuration,
|
||||
container_id: ContainerId::new([0x11; 32]),
|
||||
expected_network_namespace: current_network_namespace(),
|
||||
root_process,
|
||||
terminal: Some(self.terminal()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_network_namespace() -> NamespaceIdentity {
|
||||
lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH)).unwrap()
|
||||
}
|
||||
|
||||
fn root_process(arguments: impl IntoIterator<Item = String>) -> RootProcess {
|
||||
RootProcess::new("/sbin/lazaret-sandbox-init", arguments).unwrap()
|
||||
}
|
||||
|
||||
fn value(bundle: &lazaret_runtime_supervisor::sandbox::SandboxBundle) -> Value {
|
||||
serde_json::from_slice(bundle.specification_json()).unwrap()
|
||||
}
|
||||
|
||||
fn mounts(specification: &Value) -> BTreeMap<&str, &Value> {
|
||||
specification["mounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|mount| (mount["destination"].as_str().unwrap(), mount))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn options(mount: &Value) -> BTreeSet<&str> {
|
||||
mount["options"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|option| option.as_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape() {
|
||||
let fixture = Fixture::new();
|
||||
let arguments = vec!["literal;$(touch /host)".to_owned(), "two words".to_owned()];
|
||||
let first = build_sandbox_bundle(fixture.request(root_process(arguments.clone()))).unwrap();
|
||||
let second = build_sandbox_bundle(fixture.request(root_process(arguments.clone()))).unwrap();
|
||||
assert_eq!(first, second);
|
||||
|
||||
let specification = value(&first);
|
||||
assert_eq!(specification["ociVersion"], "1.2.1");
|
||||
assert_eq!(specification["hostname"], "lazaret");
|
||||
assert_eq!(specification["process"]["terminal"], false);
|
||||
assert_eq!(specification["process"]["noNewPrivileges"], true);
|
||||
assert_eq!(specification["process"]["cwd"], WORKSPACE_DESTINATION);
|
||||
assert_eq!(
|
||||
specification["process"]["args"],
|
||||
serde_json::json!([
|
||||
"/sbin/lazaret-sandbox-init",
|
||||
"literal;$(touch /host)",
|
||||
"two words"
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
specification["process"]["capabilities"],
|
||||
serde_json::json!({
|
||||
"bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": []
|
||||
})
|
||||
);
|
||||
|
||||
let namespaces = specification["linux"]["namespaces"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
namespaces
|
||||
.iter()
|
||||
.map(|namespace| namespace["type"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["pid", "ipc", "uts", "mount", "user", "network"]
|
||||
);
|
||||
for namespace in &namespaces[..5] {
|
||||
assert!(namespace.get("path").is_none());
|
||||
}
|
||||
assert_eq!(namespaces[5]["path"], NETWORK_NAMESPACE_PATH);
|
||||
assert_eq!(
|
||||
specification["linux"]["uidMappings"],
|
||||
serde_json::json!([{ "hostID": 0, "containerID": 0, "size": 1 }])
|
||||
);
|
||||
assert_eq!(
|
||||
specification["linux"]["gidMappings"],
|
||||
serde_json::json!([{ "hostID": 0, "containerID": 0, "size": 1 }])
|
||||
);
|
||||
|
||||
assert_eq!(first.command().program(), OsStr::new("runsc"));
|
||||
let runtime_domain = String::from_utf8(
|
||||
fixture
|
||||
.configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
first
|
||||
.command()
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|argument| argument.to_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
format!("-root={runtime_domain}/runsc"),
|
||||
"-ignore-cgroups".to_owned(),
|
||||
"run".to_owned(),
|
||||
format!(
|
||||
"-bundle={}/bundles/{}",
|
||||
String::from_utf8(
|
||||
fixture
|
||||
.configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
)
|
||||
.unwrap(),
|
||||
"11".repeat(32)
|
||||
),
|
||||
"11".repeat(32),
|
||||
]
|
||||
);
|
||||
assert!(!first.command().arguments().iter().any(|argument| {
|
||||
matches!(
|
||||
argument.to_str(),
|
||||
Some("-detach" | "-console-socket" | "-c")
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_environment_volume_modes_and_host_terminal_are_preserved() {
|
||||
let fixture = Fixture::new();
|
||||
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
||||
let specification = value(&bundle);
|
||||
let environment = specification["process"]["env"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|entry| entry.as_str().unwrap())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(environment.contains("SELECTED_SECRET=selected-value"));
|
||||
assert!(environment.contains("TERM=xterm-lazaret"));
|
||||
assert!(environment.contains("COLORTERM=truecolor"));
|
||||
assert!(environment.contains("TERMINFO_DIRS=/run/lazaret/terminfo:"));
|
||||
|
||||
let mounts = mounts(&specification);
|
||||
assert!(options(mounts[WORKSPACE_DESTINATION]).contains("rw"));
|
||||
assert!(options(mounts["/root/read-only"]).contains("ro"));
|
||||
assert!(options(mounts["/root/read-write"]).contains("rw"));
|
||||
let terminfo = &mounts["/run/lazaret/terminfo/x/xterm-lazaret"];
|
||||
assert_eq!(
|
||||
terminfo["source"],
|
||||
fixture.terminfo_source.display().to_string()
|
||||
);
|
||||
assert!(
|
||||
options(terminfo).is_superset(&BTreeSet::from(["bind", "ro", "nodev", "nosuid", "noexec"]))
|
||||
);
|
||||
|
||||
assert!(options(mounts[CREDENTIALS_DESTINATION]).contains("ro"));
|
||||
assert!(options(mounts[MAILBOX_REQUESTS_DESTINATION]).contains("rw"));
|
||||
assert!(options(mounts[MAILBOX_RESULTS_DESTINATION]).contains("ro"));
|
||||
assert!(options(mounts[ESCALATION_CLIENT_DESTINATION]).contains("ro"));
|
||||
assert!(!options(mounts[ESCALATION_CLIENT_DESTINATION]).contains("noexec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_document_unselected_secret_and_unselected_host_paths_do_not_enter_bundle() {
|
||||
let fixture = Fixture::new();
|
||||
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
||||
let bytes = bundle.specification_json();
|
||||
for forbidden in [
|
||||
b"lazaret-trusted-host-config-v1".as_slice(),
|
||||
b"UNSELECTED_SECRET".as_slice(),
|
||||
b"must-not-enter".as_slice(),
|
||||
b"example.invalid/unselected:v1".as_slice(),
|
||||
b"configuration/lazaret.toml".as_slice(),
|
||||
] {
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(forbidden.len())
|
||||
.any(|window| window == forbidden)
|
||||
);
|
||||
}
|
||||
|
||||
let specification = value(&bundle);
|
||||
let allowed_external_sources = BTreeSet::from([
|
||||
fixture.terminfo_source.display().to_string(),
|
||||
String::from_utf8(
|
||||
fixture
|
||||
.configuration
|
||||
.workspace()
|
||||
.canonical_label()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap(),
|
||||
String::from_utf8(
|
||||
fixture.configuration.volumes()[0]
|
||||
.record()
|
||||
.canonical_host_source
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap(),
|
||||
String::from_utf8(
|
||||
fixture.configuration.volumes()[1]
|
||||
.record()
|
||||
.canonical_host_source
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap(),
|
||||
]);
|
||||
let runtime_domain = String::from_utf8(
|
||||
fixture
|
||||
.configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
for mount in specification["mounts"].as_array().unwrap() {
|
||||
let Some(source) = mount.get("source").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if source.starts_with('/') {
|
||||
assert!(
|
||||
allowed_external_sources.contains(source) || source.starts_with(&runtime_domain)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_terminal_names_sources_and_changed_entries_fail_closed() {
|
||||
let fixture = Fixture::new();
|
||||
for term in ["../xterm", "xterm/name", "xterm\nname", "", &"x".repeat(65)] {
|
||||
let error = HostTerminal::from_trusted_roots(
|
||||
term,
|
||||
None,
|
||||
&fixture.terminfo_source,
|
||||
std::slice::from_ref(&fixture.terminfo_root),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), SandboxBuildErrorKind::InvalidTerminal);
|
||||
}
|
||||
let outside = fixture.tree.path().join("outside").join("xterm-lazaret");
|
||||
fs::create_dir(outside.parent().unwrap()).unwrap();
|
||||
fs::write(&outside, b"outside").unwrap();
|
||||
assert!(
|
||||
HostTerminal::from_trusted_roots(
|
||||
"xterm-lazaret",
|
||||
None,
|
||||
&outside,
|
||||
std::slice::from_ref(&fixture.terminfo_root),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let terminal = fixture.terminal();
|
||||
fs::remove_file(&fixture.terminfo_source).unwrap();
|
||||
fs::write(&fixture.terminfo_source, b"replacement").unwrap();
|
||||
let error = build_sandbox_bundle(SandboxBuildRequest {
|
||||
configuration: &fixture.configuration,
|
||||
container_id: ContainerId::new([0x11; 32]),
|
||||
expected_network_namespace: current_network_namespace(),
|
||||
root_process: root_process([]),
|
||||
terminal: Some(terminal),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_processes_and_wrong_network_identity_fail_closed() {
|
||||
assert!(RootProcess::new("bin/agent", []).is_err());
|
||||
assert!(RootProcess::new("/bin/agent", ["nul\0argument".to_owned()]).is_err());
|
||||
|
||||
let fixture = Fixture::new();
|
||||
for literal in [
|
||||
"$(touch /host)",
|
||||
"`touch /host`",
|
||||
"a; touch /host",
|
||||
"a && touch /host",
|
||||
"a | touch /host",
|
||||
"*.secret",
|
||||
"$HOME",
|
||||
] {
|
||||
let bundle =
|
||||
build_sandbox_bundle(fixture.request(root_process([literal.to_owned()]))).unwrap();
|
||||
assert_eq!(
|
||||
value(&bundle)["process"]["args"],
|
||||
serde_json::json!(["/sbin/lazaret-sandbox-init", literal])
|
||||
);
|
||||
}
|
||||
let error = build_sandbox_bundle(SandboxBuildRequest {
|
||||
configuration: &fixture.configuration,
|
||||
container_id: ContainerId::new([0x11; 32]),
|
||||
expected_network_namespace: NamespaceIdentity {
|
||||
device: DeviceId::new(u64::MAX),
|
||||
inode: InodeId::new(u64::MAX),
|
||||
},
|
||||
root_process: root_process([]),
|
||||
terminal: Some(fixture.terminal()),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
SandboxBuildErrorKind::NetworkNamespaceMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_layout_reserves_every_mount_root() {
|
||||
let layout = production_sandbox_layout().unwrap();
|
||||
assert_eq!(
|
||||
layout.protected_destinations(),
|
||||
&BTreeSet::from([
|
||||
"/dev".to_owned(),
|
||||
"/proc".to_owned(),
|
||||
"/run/lazaret".to_owned(),
|
||||
"/sys".to_owned(),
|
||||
"/workspace".to_owned(),
|
||||
])
|
||||
);
|
||||
assert_eq!(TERMINFO_ROOT_DESTINATION, "/run/lazaret/terminfo");
|
||||
}
|
||||
Loading…
Reference in a new issue