Confine package ownership changes to private mounts
This commit is contained in:
parent
2c420012bd
commit
8334fd379f
13 changed files with 402 additions and 25 deletions
|
|
@ -275,8 +275,23 @@ impl IgnoredWorkspace {
|
||||||
let stat = match backing {
|
let stat = match backing {
|
||||||
Classification::Host => host,
|
Classification::Host => host,
|
||||||
Classification::Isolated => isolated,
|
Classification::Isolated => isolated,
|
||||||
}
|
};
|
||||||
.ok_or_else(|| io::Error::from_raw_os_error(libc::ENOENT))?;
|
let (backing, stat) = if let Some(stat) = stat {
|
||||||
|
(backing, stat)
|
||||||
|
} else if backing == Classification::Isolated
|
||||||
|
&& host.as_ref().is_some_and(|stat| {
|
||||||
|
stat.st_mode & libc::S_IFMT == libc::S_IFDIR
|
||||||
|
&& self
|
||||||
|
.provenance
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.matches(path, &crate::migration::ObjectStamp::from_stat(stat))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
(Classification::Host, host.expect("checked host directory"))
|
||||||
|
} else {
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::ENOENT));
|
||||||
|
};
|
||||||
Ok(ResolvedEntry {
|
Ok(ResolvedEntry {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
backing,
|
backing,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,63 @@ fn disabled_isolation_routes_ignored_paths_to_the_host_backing() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newly_ignored_created_directory_remains_a_safe_traversal_shell() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
repository(&fixture.workspace);
|
||||||
|
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
|
||||||
|
for path in ["environment", "environment/bin"] {
|
||||||
|
fs::create_dir(fixture.workspace.join(path)).unwrap();
|
||||||
|
filesystem
|
||||||
|
.record_created(Classification::Host, std::path::Path::new(path))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fs::write(fixture.workspace.join("environment/.gitignore"), b"*\n").unwrap();
|
||||||
|
filesystem
|
||||||
|
.record_created(
|
||||||
|
Classification::Host,
|
||||||
|
std::path::Path::new("environment/.gitignore"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let path = std::path::Path::new("environment/bin");
|
||||||
|
let snapshot = filesystem.classifier.snapshot_for(path);
|
||||||
|
let entry = filesystem.resolve(&snapshot, path).unwrap();
|
||||||
|
assert_eq!(entry.backing, Classification::Host);
|
||||||
|
assert_eq!(
|
||||||
|
filesystem.classify(&snapshot, &path.join("python3"), false),
|
||||||
|
Classification::Isolated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignored_host_directory_requires_current_creation_provenance() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
repository(&fixture.workspace);
|
||||||
|
fs::write(fixture.workspace.join(".gitignore"), b"hidden/\nchanged/\n").unwrap();
|
||||||
|
fs::create_dir(fixture.workspace.join("hidden")).unwrap();
|
||||||
|
fs::create_dir(fixture.workspace.join("changed")).unwrap();
|
||||||
|
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
|
||||||
|
|
||||||
|
let hidden = std::path::Path::new("hidden");
|
||||||
|
let snapshot = filesystem.classifier.snapshot_for(hidden);
|
||||||
|
let Err(error) = filesystem.resolve(&snapshot, hidden) else {
|
||||||
|
panic!("unproven host directory became visible");
|
||||||
|
};
|
||||||
|
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
|
||||||
|
|
||||||
|
filesystem
|
||||||
|
.record_created(Classification::Host, std::path::Path::new("changed"))
|
||||||
|
.unwrap();
|
||||||
|
fs::write(fixture.workspace.join("changed/external"), b"outside").unwrap();
|
||||||
|
let changed = std::path::Path::new("changed");
|
||||||
|
let snapshot = filesystem.classifier.snapshot_for(changed);
|
||||||
|
let Err(error) = filesystem.resolve(&snapshot, changed) else {
|
||||||
|
panic!("changed host directory retained creation provenance");
|
||||||
|
};
|
||||||
|
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires a usable host FUSE device"]
|
#[ignore = "requires a usable host FUSE device"]
|
||||||
fn physical_router_preserves_lock_domains_across_transition() {
|
fn physical_router_preserves_lock_domains_across_transition() {
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ impl Provenance {
|
||||||
self.objects.extend(moved);
|
self.objects.extend(moved);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches(&self, path: &Path, stamp: &ObjectStamp) -> bool {
|
pub(crate) fn matches(&self, path: &Path, stamp: &ObjectStamp) -> bool {
|
||||||
self.objects.get(path) == Some(stamp)
|
self.objects.get(path) == Some(stamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
|
@ -40,17 +41,19 @@ use validation::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
||||||
pub const RUNSC_BUILD_ID: &str = "904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4";
|
pub const RUNSC_BUILD_ID: &str = "b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
|
||||||
pub const RUNSC_BUILD_ID_BYTES: [u8; 32] = [
|
pub const RUNSC_BUILD_ID_BYTES: [u8; 32] = [
|
||||||
144, 74, 179, 201, 189, 76, 153, 162, 113, 129, 7, 111, 44, 158, 162, 254, 204, 4, 29, 181,
|
185, 173, 111, 147, 168, 232, 144, 181, 147, 80, 97, 88, 43, 26, 18, 134, 130, 243, 253, 65,
|
||||||
167, 40, 201, 153, 84, 13, 102, 100, 23, 77, 44, 212,
|
200, 5, 60, 84, 208, 228, 79, 244, 93, 128, 153, 113,
|
||||||
];
|
];
|
||||||
pub const RUNSC_EXECUTABLE_NAME: &str =
|
pub const RUNSC_EXECUTABLE_NAME: &str =
|
||||||
"lazaret-runsc-904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4";
|
"lazaret-runsc-b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
|
||||||
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
|
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
|
||||||
|
|
||||||
const OCI_VERSION: &str = "1.2.1";
|
const OCI_VERSION: &str = "1.2.1";
|
||||||
const MAX_TERMINFO_BYTES: usize = 1_048_576;
|
const MAX_TERMINFO_BYTES: usize = 1_048_576;
|
||||||
|
const GVISOR_MOUNT_ANNOTATION_PREFIX: &str = "dev.gvisor.spec.mount.";
|
||||||
|
const OWNER_CHANGE_MOUNTS_ANNOTATION: &str = "dev.lazaret.runsc.owner-change-mounts";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum SandboxBuildErrorKind {
|
pub enum SandboxBuildErrorKind {
|
||||||
|
|
@ -691,8 +694,10 @@ fn build_sandbox_bundle_from_source(
|
||||||
.readonly(false)
|
.readonly(false)
|
||||||
.build()
|
.build()
|
||||||
.map_err(oci_error)?;
|
.map_err(oci_error)?;
|
||||||
|
let annotations = build_mount_annotations(request.configuration, &mounts)?;
|
||||||
let specification = SpecBuilder::default()
|
let specification = SpecBuilder::default()
|
||||||
.version(OCI_VERSION)
|
.version(OCI_VERSION)
|
||||||
|
.annotations(annotations)
|
||||||
.root(root)
|
.root(root)
|
||||||
.mounts(mounts)
|
.mounts(mounts)
|
||||||
.process(process)
|
.process(process)
|
||||||
|
|
@ -723,6 +728,54 @@ fn build_sandbox_bundle_from_source(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_mount_annotations(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
mounts: &[Mount],
|
||||||
|
) -> Result<HashMap<String, String>, SandboxBuildError> {
|
||||||
|
let owner_change_mounts = std::iter::once("/".to_owned())
|
||||||
|
.chain(
|
||||||
|
configuration
|
||||||
|
.fingerprint_record()
|
||||||
|
.persistent_dirs
|
||||||
|
.iter()
|
||||||
|
.cloned(),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut annotations = HashMap::from([(
|
||||||
|
OWNER_CHANGE_MOUNTS_ANNOTATION.to_owned(),
|
||||||
|
serde_json::to_string(&owner_change_mounts).map_err(|error| {
|
||||||
|
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
|
||||||
|
})?,
|
||||||
|
)]);
|
||||||
|
for (index, mount) in mounts.iter().enumerate() {
|
||||||
|
let destination = mount.destination().to_str().ok_or_else(|| {
|
||||||
|
SandboxBuildError::new(
|
||||||
|
SandboxBuildErrorKind::InvalidPath,
|
||||||
|
"mount destination is not UTF-8",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if mount.typ().as_deref() != Some("bind")
|
||||||
|
|| owner_change_mounts
|
||||||
|
.iter()
|
||||||
|
.any(|allowed| allowed == destination)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(source) = mount.source().as_ref().and_then(|path| path.to_str()) else {
|
||||||
|
return Err(SandboxBuildError::new(
|
||||||
|
SandboxBuildErrorKind::InvalidPath,
|
||||||
|
"bind mount source is not UTF-8",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let prefix = format!("{GVISOR_MOUNT_ANNOTATION_PREFIX}lazaret{index}.");
|
||||||
|
annotations.insert(format!("{prefix}source"), source.to_owned());
|
||||||
|
annotations.insert(format!("{prefix}type"), "bind".to_owned());
|
||||||
|
annotations.insert(format!("{prefix}share"), "shared".to_owned());
|
||||||
|
annotations.insert(format!("{prefix}directfs"), "off".to_owned());
|
||||||
|
}
|
||||||
|
Ok(annotations)
|
||||||
|
}
|
||||||
|
|
||||||
fn canonical_specification_json(specification: &Spec) -> Result<Vec<u8>, SandboxBuildError> {
|
fn canonical_specification_json(specification: &Spec) -> Result<Vec<u8>, SandboxBuildError> {
|
||||||
let mut value = serde_json::to_value(specification).map_err(|error| {
|
let mut value = serde_json::to_value(specification).map_err(|error| {
|
||||||
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
|
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
|
||||||
|
|
|
||||||
|
|
@ -338,8 +338,15 @@ pub(super) fn build_process(
|
||||||
root_process: RootProcess,
|
root_process: RootProcess,
|
||||||
_terminal: Option<&StagedHostTerminal>,
|
_terminal: Option<&StagedHostTerminal>,
|
||||||
) -> Result<oci_spec::runtime::Process, SandboxBuildError> {
|
) -> Result<oci_spec::runtime::Process, SandboxBuildError> {
|
||||||
static IDENTITY_TRANSITION_CAPABILITIES: LazyLock<Capabilities> =
|
static IDENTITY_TRANSITION_CAPABILITIES: LazyLock<Capabilities> = LazyLock::new(|| {
|
||||||
LazyLock::new(|| Capabilities::from([Capability::Setgid, Capability::Setuid]));
|
Capabilities::from([
|
||||||
|
Capability::Chown,
|
||||||
|
Capability::DacOverride,
|
||||||
|
Capability::Fowner,
|
||||||
|
Capability::Setgid,
|
||||||
|
Capability::Setuid,
|
||||||
|
])
|
||||||
|
});
|
||||||
let empty = Capabilities::new();
|
let empty = Capabilities::new();
|
||||||
let identity = IDENTITY_TRANSITION_CAPABILITIES.clone();
|
let identity = IDENTITY_TRANSITION_CAPABILITIES.clone();
|
||||||
let capabilities = LinuxCapabilitiesBuilder::default()
|
let capabilities = LinuxCapabilitiesBuilder::default()
|
||||||
|
|
|
||||||
|
|
@ -303,14 +303,42 @@ fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape()
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
specification["process"]["capabilities"],
|
specification["process"]["capabilities"],
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"bounding": ["CAP_SETGID", "CAP_SETUID"],
|
"bounding": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
|
||||||
"effective": ["CAP_SETGID", "CAP_SETUID"],
|
"effective": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
|
||||||
"inheritable": [],
|
"inheritable": [],
|
||||||
"permitted": ["CAP_SETGID", "CAP_SETUID"],
|
"permitted": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
|
||||||
"ambient": []
|
"ambient": []
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let annotations = specification["annotations"].as_object().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Vec<String>>(
|
||||||
|
annotations["dev.lazaret.runsc.owner-change-mounts"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
["/", "/root", "/usr", "/var", "/etc", "/opt"]
|
||||||
|
);
|
||||||
|
let owner_change_mounts = BTreeSet::from(["/", "/root", "/usr", "/var", "/etc", "/opt"]);
|
||||||
|
for (index, mount) in specification["mounts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let destination = mount["destination"].as_str().unwrap();
|
||||||
|
if mount["type"] != "bind" || owner_change_mounts.contains(destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prefix = format!("dev.gvisor.spec.mount.lazaret{index}.");
|
||||||
|
assert_eq!(annotations[&format!("{prefix}source")], mount["source"]);
|
||||||
|
assert_eq!(annotations[&format!("{prefix}type")], "bind");
|
||||||
|
assert_eq!(annotations[&format!("{prefix}share")], "shared");
|
||||||
|
assert_eq!(annotations[&format!("{prefix}directfs")], "off");
|
||||||
|
}
|
||||||
|
|
||||||
let namespaces = specification["linux"]["namespaces"].as_array().unwrap();
|
let namespaces = specification["linux"]["namespaces"].as_array().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
namespaces
|
namespaces
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,7 @@ pub(super) fn create_regular_file(parent: impl AsFd, name: &str) -> Result<(), S
|
||||||
parent.as_fd(),
|
parent.as_fd(),
|
||||||
name,
|
name,
|
||||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
Mode::from_raw_mode(0o600),
|
Mode::from_raw_mode(0o644),
|
||||||
confined_resolve_flags(),
|
confined_resolve_flags(),
|
||||||
) {
|
) {
|
||||||
Ok(descriptor) => File::from(descriptor).sync_all()?,
|
Ok(descriptor) => File::from(descriptor).sync_all()?,
|
||||||
|
|
@ -440,7 +440,7 @@ pub(super) fn atomic_replace_at(
|
||||||
parent.as_fd(),
|
parent.as_fd(),
|
||||||
Path::new(&candidate),
|
Path::new(&candidate),
|
||||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
Mode::from_raw_mode(0o600),
|
Mode::from_raw_mode(0o644),
|
||||||
confined_resolve_flags(),
|
confined_resolve_flags(),
|
||||||
) {
|
) {
|
||||||
Ok(descriptor) => {
|
Ok(descriptor) => {
|
||||||
|
|
@ -514,6 +514,7 @@ pub(super) fn overlay_options(lower: &Path, upper: &Path, work: &Path) -> OsStri
|
||||||
append_fuse_option_path(&mut bytes, upper);
|
append_fuse_option_path(&mut bytes, upper);
|
||||||
bytes.extend_from_slice(b",workdir=");
|
bytes.extend_from_slice(b",workdir=");
|
||||||
append_fuse_option_path(&mut bytes, work);
|
append_fuse_option_path(&mut bytes, work);
|
||||||
|
bytes.extend_from_slice(b",xattr_permissions=2,noacl");
|
||||||
OsString::from_vec(bytes)
|
OsString::from_vec(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ fn run() -> io::Result<()> {
|
||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
}
|
}
|
||||||
|
enter_runtime_user_namespace()?;
|
||||||
let tree = TestTree::new().map_err(|error| io::Error::other(format!("test tree: {error}")))?;
|
let tree = TestTree::new().map_err(|error| io::Error::other(format!("test tree: {error}")))?;
|
||||||
let configuration = configuration(&tree)
|
let configuration = configuration(&tree)
|
||||||
.map_err(|error| io::Error::other(format!("configuration: {error}")))?;
|
.map_err(|error| io::Error::other(format!("configuration: {error}")))?;
|
||||||
|
|
@ -251,6 +252,25 @@ fn run() -> io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
|
fn enter_runtime_user_namespace() -> io::Result<()> {
|
||||||
|
let uid = rustix::process::getuid().as_raw();
|
||||||
|
let gid = rustix::process::getgid().as_raw();
|
||||||
|
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWUSER)
|
||||||
|
.map_err(|error| io::Error::other(format!("fixture user namespace: {error}")))?;
|
||||||
|
fs::write("/proc/self/setgroups", "deny")?;
|
||||||
|
fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
|
||||||
|
fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
|
||||||
|
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWNS)
|
||||||
|
.map_err(|error| io::Error::other(format!("fixture mount namespace: {error}")))?;
|
||||||
|
rustix::mount::mount_change(
|
||||||
|
"/",
|
||||||
|
rustix::mount::MountPropagationFlags::PRIVATE | rustix::mount::MountPropagationFlags::REC,
|
||||||
|
)
|
||||||
|
.map_err(|error| io::Error::other(format!("fixture private propagation: {error}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
fn run_private_recovery_holder(arguments: &[OsString]) -> io::Result<()> {
|
fn run_private_recovery_holder(arguments: &[OsString]) -> io::Result<()> {
|
||||||
if arguments.len() != 2 {
|
if arguments.len() != 2 {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,16 @@ The State Anchor preserves developer tooling and selected sandbox system paths f
|
||||||
one Domain. It combines a Lazaret-owned prepared lower from the selected base
|
one Domain. It combines a Lazaret-owned prepared lower from the selected base
|
||||||
image with a Domain-scoped writable upper directory.
|
image with a Domain-scoped writable upper directory.
|
||||||
|
|
||||||
|
The Agent Sandbox process receives `CAP_CHOWN`, `CAP_DAC_OVERRIDE`,
|
||||||
|
`CAP_FOWNER`, `CAP_SETGID`, and `CAP_SETUID` inside runsc while retaining
|
||||||
|
`noNewPrivileges`. Runsc permits ownership changes only on the root filesystem
|
||||||
|
and configured State Anchor persistent destinations. Every other bind mount,
|
||||||
|
including the Agent Workspace, control mounts, and developer-configured
|
||||||
|
volumes, uses LisaFS with directfs disabled and rejects UID or GID changes
|
||||||
|
before applying any requested attributes. State Anchor uses fuse-overlayfs
|
||||||
|
virtual ownership metadata so package-created identities persist without
|
||||||
|
changing the rootless host ownership of its backing files.
|
||||||
|
|
||||||
#### Prepared lower
|
#### Prepared lower
|
||||||
|
|
||||||
Preparation resolves the selected base image, applies layers with a supported
|
Preparation resolves the selected base image, applies layers with a supported
|
||||||
|
|
@ -1140,6 +1150,7 @@ not duplicated here.
|
||||||
| **SR-13** | Git-ignored Agent Workspace paths shall use Ignored Workspace State by default, preventing the sandbox from reading or modifying corresponding host content; only trusted host configuration may disable this behavior. |
|
| **SR-13** | Git-ignored Agent Workspace paths shall use Ignored Workspace State by default, preventing the sandbox from reading or modifying corresponding host content; only trusted host configuration may disable this behavior. |
|
||||||
| **SR-14** | State Anchor maintenance shall remain confined to the resolved Domain Root. Destructive maintenance shall require exclusive lifecycle ownership and an inactive, unmounted target. |
|
| **SR-14** | State Anchor maintenance shall remain confined to the resolved Domain Root. Destructive maintenance shall require exclusive lifecycle ownership and an inactive, unmounted target. |
|
||||||
| **SR-15** | Lazaret shall not use a live container-engine image mount as the State Anchor lower; the lower shall be a Lazaret-owned prepared lower. Archive export alone shall not be treated as a complete preparation boundary. |
|
| **SR-15** | Lazaret shall not use a live container-engine image mount as the State Anchor lower; the lower shall be a Lazaret-owned prepared lower. Archive export alone shall not be treated as a complete preparation boundary. |
|
||||||
|
| **SR-16** | Ownership-changing Agent Sandbox capabilities shall affect only the root filesystem and configured State Anchor persistent destinations. Host-backed bind mounts shall reject UID and GID changes before applying any requested attributes. |
|
||||||
|
|
||||||
### 4.2 Functional Requirements
|
### 4.2 Functional Requirements
|
||||||
|
|
||||||
|
|
|
||||||
167
patches/gvisor-9f8f6c917-private-ownership.patch
Normal file
167
patches/gvisor-9f8f6c917-private-ownership.patch
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
diff --git a/runsc/cmd/gofer.go b/runsc/cmd/gofer.go
|
||||||
|
index 2cfbae6..8d7bc35 100644
|
||||||
|
--- a/runsc/cmd/gofer.go
|
||||||
|
+++ b/runsc/cmd/gofer.go
|
||||||
|
@@ -16,6 +16,7 @@ package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
+ "encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
@@ -42,6 +43,8 @@ import (
|
||||||
|
"gvisor.dev/gvisor/runsc/specutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
+const ownerChangeMountsAnnotation = "dev.lazaret.runsc.owner-change-mounts"
|
||||||
|
+
|
||||||
|
var caps = []string{
|
||||||
|
"CAP_CHOWN",
|
||||||
|
"CAP_DAC_OVERRIDE",
|
||||||
|
@@ -398,6 +401,30 @@ func (g *Gofer) serve(spec *specs.Spec, conf *config.Config, root string, ruid i
|
||||||
|
log.Infof("Serving /dev mapped on FD %d (ro: false)", g.devIoFD)
|
||||||
|
}
|
||||||
|
|
||||||
|
+ servedMounts := make(map[string]bool, len(cfgs))
|
||||||
|
+ for _, cfg := range cfgs {
|
||||||
|
+ servedMounts[cfg.mountPath] = true
|
||||||
|
+ }
|
||||||
|
+ ownerChangeMounts := make(map[string]bool)
|
||||||
|
+ if encoded, ok := spec.Annotations[ownerChangeMountsAnnotation]; ok {
|
||||||
|
+ var destinations []string
|
||||||
|
+ if err := json.Unmarshal([]byte(encoded), &destinations); err != nil {
|
||||||
|
+ util.Fatalf("parsing %s annotation: %v", ownerChangeMountsAnnotation, err)
|
||||||
|
+ }
|
||||||
|
+ for _, destination := range destinations {
|
||||||
|
+ if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination {
|
||||||
|
+ util.Fatalf("invalid owner-change mount destination: %q", destination)
|
||||||
|
+ }
|
||||||
|
+ if !servedMounts[destination] {
|
||||||
|
+ util.Fatalf("owner-change destination is not a gofer mount: %q", destination)
|
||||||
|
+ }
|
||||||
|
+ if ownerChangeMounts[destination] {
|
||||||
|
+ util.Fatalf("duplicate owner-change mount destination: %q", destination)
|
||||||
|
+ }
|
||||||
|
+ ownerChangeMounts[destination] = true
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// These are global fsgofer configurations.
|
||||||
|
fsgoferConf := &fsgofer.Config{
|
||||||
|
HostUDS: conf.GetHostUDS(),
|
||||||
|
@@ -430,7 +457,10 @@ func (g *Gofer) serve(spec *specs.Spec, conf *config.Config, root string, ruid i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if connImpl == nil {
|
||||||
|
- connImpl = fsgofer.NewConnectionImpl(fsgoferConf)
|
||||||
|
+ mountConf := *fsgoferConf
|
||||||
|
+ mountConf.DenyChown = !ownerChangeMounts[cfg.mountPath]
|
||||||
|
+ mountConf.DonateMountPointFD = mountConf.DonateMountPointFD && ownerChangeMounts[cfg.mountPath]
|
||||||
|
+ connImpl = fsgofer.NewConnectionImpl(&mountConf)
|
||||||
|
connOpts = fsgofer.ConnectionOpts(cfg.readonly)
|
||||||
|
}
|
||||||
|
conn, err := server.CreateConnection(cfg.sock, cfg.mountPath, connOpts, connImpl)
|
||||||
|
diff --git a/runsc/fsgofer/lisafs.go b/runsc/fsgofer/lisafs.go
|
||||||
|
index ff6aeff..e5829b2 100644
|
||||||
|
--- a/runsc/fsgofer/lisafs.go
|
||||||
|
+++ b/runsc/fsgofer/lisafs.go
|
||||||
|
@@ -61,6 +61,9 @@ type Config struct {
|
||||||
|
// be donated to the client on Mount RPC.
|
||||||
|
DonateMountPointFD bool
|
||||||
|
|
||||||
|
+ // DenyChown rejects owner changes before applying any requested attributes.
|
||||||
|
+ DenyChown bool
|
||||||
|
+
|
||||||
|
// Gofer process's RUID.
|
||||||
|
RUID int
|
||||||
|
|
||||||
|
@@ -148,6 +151,7 @@ func (i *connectionImpl) Mount(c *lisafs.Connection, mountNode *lisafs.Node) (*l
|
||||||
|
hostFD: rootHostFD,
|
||||||
|
writableHostFD: atomicbitops.FromInt32(-1),
|
||||||
|
isMountPoint: true,
|
||||||
|
+ denyChown: i.config.DenyChown,
|
||||||
|
}
|
||||||
|
mountNode.IncRef() // Ref is transferred to ControlFD.
|
||||||
|
rootFD.ControlFD.Init(c, mountNode, linux.FileMode(stat.Mode), rootFD)
|
||||||
|
@@ -201,6 +205,7 @@ func (i *connectionImpl) SupportedMessages() []lisafs.MID {
|
||||||
|
// controlFDLisa implements lisafs.ControlFDImpl.
|
||||||
|
type controlFDLisa struct {
|
||||||
|
lisafs.ControlFD
|
||||||
|
+ denyChown bool
|
||||||
|
|
||||||
|
// hostFD is the file descriptor which can be used to make host syscalls.
|
||||||
|
hostFD int
|
||||||
|
@@ -245,6 +250,7 @@ func newControlFDLisa(hostFD int, parent *controlFDLisa, name string, mode linux
|
||||||
|
})
|
||||||
|
childFD.hostFD = hostFD
|
||||||
|
childFD.writableHostFD = atomicbitops.FromInt32(-1)
|
||||||
|
+ childFD.denyChown = parent.denyChown
|
||||||
|
childFD.ControlFD.Init(parent.Conn(), childNode, mode, childFD)
|
||||||
|
return childFD
|
||||||
|
}
|
||||||
|
@@ -304,6 +310,9 @@ func (fd *controlFDLisa) Stat() (lisafs.Statx, error) {
|
||||||
|
|
||||||
|
// SetStat implements lisafs.ControlFDImpl.SetStat.
|
||||||
|
func (fd *controlFDLisa) SetStat(stat lisafs.SetStatReq) (failureMask uint32, failureErr error) {
|
||||||
|
+ if fd.denyChown && stat.Mask&(unix.STATX_UID|unix.STATX_GID) != 0 {
|
||||||
|
+ return stat.Mask, unix.EPERM
|
||||||
|
+ }
|
||||||
|
if stat.Mask&unix.STATX_MODE != 0 {
|
||||||
|
switch fd.FileType() {
|
||||||
|
case unix.S_IFLNK:
|
||||||
|
@@ -705,9 +719,11 @@ func (fd *controlFDLisa) Symlink(name string, target string, uid lisafs.UID, gid
|
||||||
|
if err != nil {
|
||||||
|
return nil, lisafs.Statx{}, err
|
||||||
|
}
|
||||||
|
- if err := fchown(symlinkFD, uid, gid); err != nil {
|
||||||
|
- unix.Close(symlinkFD)
|
||||||
|
- return nil, lisafs.Statx{}, err
|
||||||
|
+ if !fd.denyChown {
|
||||||
|
+ if err := fchown(symlinkFD, uid, gid); err != nil {
|
||||||
|
+ unix.Close(symlinkFD)
|
||||||
|
+ return nil, lisafs.Statx{}, err
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
symlinkStat, err := fstatTo(symlinkFD)
|
||||||
|
@@ -1072,4 +1083,2 @@ func (fd *controlFDLisa) ListXattr(size uint64) (lisafs.StringArray, error) {
|
||||||
|
- if size > linux.XATTR_LIST_MAX || size == 0 {
|
||||||
|
- size = linux.XATTR_LIST_MAX
|
||||||
|
- }
|
||||||
|
+ size = linux.XATTR_LIST_MAX
|
||||||
|
bPtr := listXattrBufPool.Get().(*[]byte)
|
||||||
|
diff --git a/runsc/fsgofer/lisafs_test.go b/runsc/fsgofer/lisafs_test.go
|
||||||
|
index b10eefc..676960c 100644
|
||||||
|
--- a/runsc/fsgofer/lisafs_test.go
|
||||||
|
+++ b/runsc/fsgofer/lisafs_test.go
|
||||||
|
@@ -61,3 +61,19 @@ func (tester) BindSupported() bool {
|
||||||
|
func TestFSGofer(t *testing.T) {
|
||||||
|
testsuite.RunAllLocalFSTests(t, tester{})
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+type denyChownTester struct {
|
||||||
|
+ tester
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (denyChownTester) NewConnImpl(t *testing.T) lisafs.ConnectionImpl {
|
||||||
|
+ return fsgofer.NewConnectionImpl(&fsgofer.Config{DenyChown: true})
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (denyChownTester) SetUserGroupIDSupported() bool {
|
||||||
|
+ return false
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func TestFSGoferDenyChown(t *testing.T) {
|
||||||
|
+ testsuite.RunAllLocalFSTests(t, denyChownTester{})
|
||||||
|
+}
|
||||||
|
diff --git a/pkg/sentry/fsimpl/gofer/directfs_inode.go b/pkg/sentry/fsimpl/gofer/directfs_inode.go
|
||||||
|
index a7c1ab1..d3a0d96 100644
|
||||||
|
--- a/pkg/sentry/fsimpl/gofer/directfs_inode.go
|
||||||
|
+++ b/pkg/sentry/fsimpl/gofer/directfs_inode.go
|
||||||
|
@@ -612,4 +612,2 @@ func (i *directfsInode) listXattr(ctx context.Context, size uint64, d *dentry) ([]string, error) {
|
||||||
|
- if size > linux.XATTR_LIST_MAX || size == 0 {
|
||||||
|
- size = linux.XATTR_LIST_MAX
|
||||||
|
- }
|
||||||
|
+ size = linux.XATTR_LIST_MAX
|
||||||
|
bPtr := xattrBufPool.Get().(*[]byte)
|
||||||
|
|
@ -406,7 +406,7 @@ class VerificationTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_runsc_materializer_and_install_identity_are_exact(self) -> None:
|
def test_runsc_materializer_and_install_identity_are_exact(self) -> None:
|
||||||
build_id = "904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4"
|
build_id = "b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971"
|
||||||
materializer = (ROOT / "tools/materialize-runsc").read_text()
|
materializer = (ROOT / "tools/materialize-runsc").read_text()
|
||||||
installer = (ROOT / "tools/install-to-host").read_text()
|
installer = (ROOT / "tools/install-to-host").read_text()
|
||||||
runtime = (ROOT / "crates/lazaret-runtime-supervisor/src/sandbox.rs").read_text()
|
runtime = (ROOT / "crates/lazaret-runtime-supervisor/src/sandbox.rs").read_text()
|
||||||
|
|
@ -420,6 +420,9 @@ class VerificationTests(unittest.TestCase):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(ROOT / "patches/gvisor-9f8f6c917-directory-handles.patch").is_file()
|
(ROOT / "patches/gvisor-9f8f6c917-directory-handles.patch").is_file()
|
||||||
)
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
(ROOT / "patches/gvisor-9f8f6c917-private-ownership.patch").is_file()
|
||||||
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(ROOT / "patches/gvisor-9f8f6c917-MODULE.bazel.lock").is_file()
|
(ROOT / "patches/gvisor-9f8f6c917-MODULE.bazel.lock").is_file()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ if readelf -l "$sandbox_init" | grep -q 'Requesting program interpreter' ||
|
||||||
fi
|
fi
|
||||||
install -m 755 "$sandbox_init" "$install_dir/lazaret-sandbox-init"
|
install -m 755 "$sandbox_init" "$install_dir/lazaret-sandbox-init"
|
||||||
|
|
||||||
runsc_build_id=904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4
|
runsc_build_id=b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971
|
||||||
runsc_name="lazaret-runsc-$runsc_build_id"
|
runsc_name="lazaret-runsc-$runsc_build_id"
|
||||||
runsc_source="$root/.lazaret/bin/$runsc_name"
|
runsc_source="$root/.lazaret/bin/$runsc_name"
|
||||||
runsc_destination="$install_dir/$runsc_name"
|
runsc_destination="$install_dir/$runsc_name"
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@ set -euo pipefail
|
||||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
revision=9f8f6c917885d9ef3d84c17280939c012dea0dfe
|
revision=9f8f6c917885d9ef3d84c17280939c012dea0dfe
|
||||||
archive_checksum=9d96426115f1bf54ef09ba41964c73dc3c809743519c8442016b8dd3fce0889d
|
archive_checksum=9d96426115f1bf54ef09ba41964c73dc3c809743519c8442016b8dd3fce0889d
|
||||||
patch_file="$root/patches/gvisor-9f8f6c917-directory-handles.patch"
|
patch_files=(
|
||||||
patch_checksum=541420eece9ec7efbef156bdb726744ead6b4d991d41c5560f4fdfe83fb2357a
|
"$root/patches/gvisor-9f8f6c917-directory-handles.patch"
|
||||||
|
"$root/patches/gvisor-9f8f6c917-private-ownership.patch"
|
||||||
|
)
|
||||||
|
patch_checksums=(
|
||||||
|
541420eece9ec7efbef156bdb726744ead6b4d991d41c5560f4fdfe83fb2357a
|
||||||
|
79a8c98502d3bb7c254ffb0885dbd26afffac227d04a0091ff91383df0842561
|
||||||
|
)
|
||||||
|
patch_digest=5622a44af2d9e7561f5372844dc21cf6fb93b4fcd3fe70da7f545d403a84c612
|
||||||
bazel_version=8.3.1
|
bazel_version=8.3.1
|
||||||
module_lock="$root/patches/gvisor-9f8f6c917-MODULE.bazel.lock"
|
module_lock="$root/patches/gvisor-9f8f6c917-MODULE.bazel.lock"
|
||||||
module_checksum=8402c7beb4baf2c666f4b78e400ea3f15514b56117598c2cb5411d6a35208d34
|
module_checksum=8402c7beb4baf2c666f4b78e400ea3f15514b56117598c2cb5411d6a35208d34
|
||||||
build_id=904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4
|
build_id=b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971
|
||||||
archive="$root/.lazaret/cache/gvisor-$revision.tar.gz"
|
archive="$root/.lazaret/cache/gvisor-$revision.tar.gz"
|
||||||
source="$root/.lazaret/vendor/gvisor-$build_id"
|
source="$root/.lazaret/vendor/gvisor-$build_id"
|
||||||
binary="$root/.lazaret/bin/lazaret-runsc-$build_id"
|
binary="$root/.lazaret/bin/lazaret-runsc-$build_id"
|
||||||
|
|
@ -33,14 +40,19 @@ for command in curl make patch sha256sum tar; do
|
||||||
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
|
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
for index in "${!patch_files[@]}"; do
|
||||||
|
patch_file="${patch_files[$index]}"
|
||||||
[[ -f "$patch_file" ]] || fail "missing patch: $patch_file"
|
[[ -f "$patch_file" ]] || fail "missing patch: $patch_file"
|
||||||
[[ "$(sha256sum "$patch_file" | cut -d ' ' -f 1)" == "$patch_checksum" ]] ||
|
[[ "$(sha256sum "$patch_file" | cut -d ' ' -f 1)" == "${patch_checksums[$index]}" ]] ||
|
||||||
fail 'patch checksum mismatch'
|
fail "patch checksum mismatch: $patch_file"
|
||||||
|
done
|
||||||
|
[[ "$(printf '%s\n' "${patch_checksums[@]}" | sha256sum | cut -d ' ' -f 1)" == "$patch_digest" ]] ||
|
||||||
|
fail 'ordered patch digest mismatch'
|
||||||
[[ -f "$module_lock" ]] || fail "missing module lock: $module_lock"
|
[[ -f "$module_lock" ]] || fail "missing module lock: $module_lock"
|
||||||
[[ "$(sha256sum "$module_lock" | cut -d ' ' -f 1)" == "$module_checksum" ]] ||
|
[[ "$(sha256sum "$module_lock" | cut -d ' ' -f 1)" == "$module_checksum" ]] ||
|
||||||
fail 'module lock checksum mismatch'
|
fail 'module lock checksum mismatch'
|
||||||
|
|
||||||
stamp="$revision $archive_checksum $patch_checksum $bazel_version $module_checksum $build_id"
|
stamp="$revision $archive_checksum $patch_digest $bazel_version $module_checksum $build_id"
|
||||||
if [[ ! -f "$source/.lazaret-materialization" ]]; then
|
if [[ ! -f "$source/.lazaret-materialization" ]]; then
|
||||||
mkdir -p "$root/.lazaret/cache" "$root/.lazaret/vendor"
|
mkdir -p "$root/.lazaret/cache" "$root/.lazaret/vendor"
|
||||||
if [[ ! -f "$archive" ]]; then
|
if [[ ! -f "$archive" ]]; then
|
||||||
|
|
@ -60,7 +72,9 @@ if [[ ! -f "$source/.lazaret-materialization" ]]; then
|
||||||
tar -xzf "$archive" -C "$temporary"
|
tar -xzf "$archive" -C "$temporary"
|
||||||
extracted="$temporary/gvisor-$revision"
|
extracted="$temporary/gvisor-$revision"
|
||||||
[[ -d "$extracted" ]] || fail 'archive has an unexpected root directory'
|
[[ -d "$extracted" ]] || fail 'archive has an unexpected root directory'
|
||||||
patch --directory="$extracted" --strip=1 --batch --forward <"$patch_file"
|
for patch_file in "${patch_files[@]}"; do
|
||||||
|
patch --directory="$extracted" --strip=1 --batch --fuzz=0 --forward <"$patch_file"
|
||||||
|
done
|
||||||
[[ "$(tr -d '\n' <"$extracted/.bazelversion")" == "$bazel_version" ]] ||
|
[[ "$(tr -d '\n' <"$extracted/.bazelversion")" == "$bazel_version" ]] ||
|
||||||
fail 'Bazel version mismatch'
|
fail 'Bazel version mismatch'
|
||||||
install -m 0644 "$module_lock" "$extracted/MODULE.bazel.lock"
|
install -m 0644 "$module_lock" "$extracted/MODULE.bazel.lock"
|
||||||
|
|
@ -73,7 +87,8 @@ elif [[ "$(tr -d '\n' <"$source/.lazaret-materialization")" != "$stamp" ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$mode" == test ]]; then
|
if [[ "$mode" == test ]]; then
|
||||||
make -C "$source" test TARGETS=//pkg/sentry/fsimpl/gofer:gofer_test
|
make -C "$source" test \
|
||||||
|
TARGETS='//pkg/sentry/fsimpl/gofer:gofer_test //runsc/fsgofer:lisafs_test'
|
||||||
fi
|
fi
|
||||||
if [[ "$mode" != source ]]; then
|
if [[ "$mode" != source ]]; then
|
||||||
make -C "$source" build TARGETS=//runsc:runsc
|
make -C "$source" build TARGETS=//runsc:runsc
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue