feat(sandbox): lease task identities and drop initialization privileges
This commit is contained in:
@@ -329,3 +329,26 @@ stdin 只保留在监督器的独立快照中,不挂载到用户工作区,
|
||||
`inputs_vm.rs` 在独立受限 VM 验收真实只读写入 EROFS、执行/noexec 区分、输入祖先
|
||||
保护、stdin 不可见、不同任务隔离,以及链接/已有文件/空间不足导致的部分挂载回滚。
|
||||
身份降权、capabilities/seccomp、用户进程树与 stdio 仍须完成后才能启用 `run`。
|
||||
|
||||
## 生产身份与权限收缩
|
||||
|
||||
`launcher/identity.rs` 的 `IdentityReducedTarget` 先在可信 state 根下的 root-owned
|
||||
0700 `identities/uid-N` 目录中原子租用任务身份,再准备输入和推进降权。活跃、遗留或
|
||||
未知标记一律不接管;池耗尽返回 EAGAIN。目标树终止、子进程回收和 root 清理均成功后
|
||||
才移除自有租约;清理失败保留标记、独立报告错误,Drop 也遵循同样顺序。服务重启
|
||||
后的遗留资源核验/回收仍是后续阶段,当前不会自动复用残留身份。
|
||||
|
||||
`isolation/identity.rs` 仅在固定单线程 PID 1 中执行:清空 supplementary groups,
|
||||
关闭 keepcaps/ambient;锁定 root 特权、ambient 提升与 keepcaps,保留并锁定正常 UID
|
||||
切换清权行为;遍历内核实际支持的全部 capability 位清空 bounding set,设置
|
||||
`no_new_privs`,统一 real/effective/saved/filesystem UID/GID,并显式清空
|
||||
effective/permitted/inheritable。凭据变更后重新设置 PDEATHSIG=SIGKILL。
|
||||
初始化器读取 securebits/keepcaps/NNP/全部 capability 与组,监督器读取同一已持有 PID
|
||||
的内核 status,再校验 root/cwd/目录 FD 和 STOP/freeze。未知 ABI、失败或不一致拒绝。
|
||||
|
||||
`identity_vm.rs` 与固定 VM fixture 验收真正的集成 init 凭据、并发独占/池耗尽/清理
|
||||
后复用/失败留租约,以及精确生产 reducer 代码在 fork/线程/exec 中的权限继承;固定
|
||||
setuid-root 文件无法恢复权限,跨 UID 信号/ptrace/文件访问拒绝。测试故障过滤器只
|
||||
注入真实 setgroups EPERM,验证失败回滚;它不构成生产 seccomp 策略。fixture 仅可在
|
||||
规定独立 VM 使用,没有向 CLI 增加用户执行命令。后续 seccomp、全任务树/stdio/计量
|
||||
和运行后清理仍未验收,`run` 继续拒绝。
|
||||
|
||||
@@ -14,3 +14,7 @@ rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mou
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "identity_probe"
|
||||
test = false
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Fixed VM-only fixture; compiles the exact private production reducer.
|
||||
//! It supplies no production user execution capability or arbitrary command.
|
||||
mod launcher {
|
||||
pub mod entry {
|
||||
pub use kf_sandbox::launcher::entry::ensure_single_threaded;
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/isolation/identity.rs"]
|
||||
mod identity;
|
||||
#[path = "../tests/support/mod.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod support;
|
||||
|
||||
fn main() {
|
||||
support::require_vm();
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[1], "fixed-probe");
|
||||
let control = std::process::Command::new("/kf/bin/identity-inherit-probe")
|
||||
.arg("--suid-control-child")
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
control.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&control.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
control.stdout,
|
||||
b"KF_SUID_CONTROL_PROOF works_without_nnp=1\n"
|
||||
);
|
||||
print!("{}", String::from_utf8_lossy(&control.stdout));
|
||||
rustix::thread::set_thread_groups(&[rustix::process::Gid::from_raw(123)]).unwrap();
|
||||
let mut caps = rustix::thread::capabilities(None).unwrap();
|
||||
caps.inheritable |= rustix::thread::CapabilitySet::CHOWN;
|
||||
rustix::thread::set_capabilities(None, caps).unwrap();
|
||||
rustix::thread::configure_capability_in_ambient_set(rustix::thread::CapabilitySet::CHOWN, true)
|
||||
.unwrap();
|
||||
rustix::thread::set_keep_capabilities(true).unwrap();
|
||||
identity::reduce_current(100000, 100000).unwrap();
|
||||
for program in [
|
||||
"/kf/bin/identity-inherit-probe",
|
||||
"/kf/bin/identity-suid-probe",
|
||||
] {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.env_clear();
|
||||
if let Some(pid) = args.get(2) {
|
||||
let pid: u32 = pid.parse().unwrap();
|
||||
assert!(pid > 1);
|
||||
command.arg(pid.to_string());
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{:?} {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert_eq!(output.stdout,b"KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1\n");
|
||||
}
|
||||
println!(
|
||||
"KF_IDENTITY_REDUCER_PROOF primed_inheritable_ambient_groups_keepcaps_cleared=1 setuid_exec_denied=1 fork_thread_inherit=1 peer_access_denied=1"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Irreversible credential reduction, called only by the fixed single-threaded init.
|
||||
use rustix::process::{Gid, Signal, Uid};
|
||||
use rustix::thread::{CapabilitiesSecureBits as Bits, CapabilitySet, CapabilitySets};
|
||||
use std::io;
|
||||
|
||||
fn denied(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
const SECURE: Bits = Bits::NO_ROOT
|
||||
.union(Bits::NO_ROOT_LOCKED)
|
||||
.union(Bits::NO_SETUID_FIXUP_LOCKED)
|
||||
.union(Bits::KEEP_CAPS_LOCKED)
|
||||
.union(Bits::NO_CAP_AMBIENT_RAISE)
|
||||
.union(Bits::NO_CAP_AMBIENT_RAISE_LOCKED);
|
||||
|
||||
pub(crate) fn valid(uid: u32, gid: u32) -> bool {
|
||||
uid >= 100_000 && gid >= 100_000 && uid != u32::MAX && gid != u32::MAX
|
||||
}
|
||||
|
||||
// Probe every supported bit rather than silently dropping only constants known
|
||||
// to this library. The 64-bit capability ABI is a deliberate fail-closed bound.
|
||||
fn last_capability() -> io::Result<u32> {
|
||||
for bit in 0..64 {
|
||||
match rustix::thread::capability_is_in_bounding_set(CapabilitySet::from_bits_retain(
|
||||
1 << bit,
|
||||
)) {
|
||||
Ok(_) => {}
|
||||
Err(rustix::io::Errno::INVAL) if bit > 0 => return Ok(bit - 1),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Err(denied("unsupported capability ABI"))
|
||||
}
|
||||
|
||||
pub(crate) fn reduce_current(uid: u32, gid: u32) -> io::Result<()> {
|
||||
crate::launcher::entry::ensure_single_threaded()?;
|
||||
if !valid(uid, gid) || !rustix::process::geteuid().is_root() {
|
||||
return Err(denied("task identity precondition"));
|
||||
}
|
||||
let last = last_capability()?;
|
||||
rustix::thread::set_thread_groups(&[])?;
|
||||
rustix::thread::set_keep_capabilities(false)?;
|
||||
rustix::thread::clear_ambient_capability_set()?;
|
||||
// Lock keepcaps and no-setuid-fixup OFF, not ON. Normal UID transition must
|
||||
// still clear privilege; root and ambient acquisition are locked off too.
|
||||
rustix::thread::set_capabilities_secure_bits(SECURE)?;
|
||||
for bit in 0..=last {
|
||||
rustix::thread::remove_capability_from_bounding_set(CapabilitySet::from_bits_retain(
|
||||
1 << bit,
|
||||
))?;
|
||||
}
|
||||
rustix::thread::set_no_new_privs(true)?;
|
||||
let gid = Gid::from_raw(gid);
|
||||
let uid = Uid::from_raw(uid);
|
||||
rustix::thread::set_thread_res_gid(gid, gid, gid)?;
|
||||
rustix::thread::set_thread_res_uid(uid, uid, uid)?;
|
||||
let empty = CapabilitySets {
|
||||
effective: CapabilitySet::empty(),
|
||||
permitted: CapabilitySet::empty(),
|
||||
inheritable: CapabilitySet::empty(),
|
||||
};
|
||||
rustix::thread::set_capabilities(None, empty)?;
|
||||
rustix::thread::clear_ambient_capability_set()?;
|
||||
// Linux clears PDEATHSIG on credential changes. Restore it before ACK;
|
||||
// the supervisor also checks the same held bootstrap/init parent relation.
|
||||
rustix::process::set_parent_process_death_signal(Some(Signal::KILL))?;
|
||||
if rustix::thread::capabilities(None)? != empty
|
||||
|| !rustix::process::getgroups()?.is_empty()
|
||||
|| rustix::process::getuid() != uid
|
||||
|| rustix::process::geteuid() != uid
|
||||
|| rustix::process::getgid() != gid
|
||||
|| rustix::process::getegid() != gid
|
||||
|| rustix::thread::get_keep_capabilities()?
|
||||
|| rustix::thread::capabilities_secure_bits()? != SECURE
|
||||
|| !rustix::thread::no_new_privs()?
|
||||
|| rustix::process::parent_process_death_signal()? != Some(Signal::KILL)
|
||||
{
|
||||
return Err(denied("task credential readback"));
|
||||
}
|
||||
for bit in 0..=last {
|
||||
let cap = CapabilitySet::from_bits_retain(1 << bit);
|
||||
if rustix::thread::capability_is_in_bounding_set(cap)?
|
||||
|| rustix::thread::capability_is_in_ambient_set(cap)?
|
||||
{
|
||||
return Err(denied("task capability readback"));
|
||||
}
|
||||
}
|
||||
verify_status(
|
||||
&std::fs::read_to_string("/proc/self/status")?,
|
||||
uid.as_raw(),
|
||||
gid.as_raw(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reduce_init(uid: u32, gid: u32) -> io::Result<()> {
|
||||
if rustix::process::getpid().as_raw_pid() != 1 {
|
||||
return Err(denied("credential stage requires namespace init"));
|
||||
}
|
||||
reduce_current(uid, gid)
|
||||
}
|
||||
|
||||
fn field<'a>(status: &'a str, name: &str) -> io::Result<&'a str> {
|
||||
status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix(name))
|
||||
.ok_or_else(|| denied("missing credential field"))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_status(status: &str, uid: u32, gid: u32) -> io::Result<()> {
|
||||
for (name, expected) in [("Uid:", uid), ("Gid:", gid)] {
|
||||
let ids: Vec<_> = field(status, name)?.split_whitespace().collect();
|
||||
if ids.len() != 4 || ids.iter().any(|s| s.parse::<u32>().ok() != Some(expected)) {
|
||||
return Err(denied("real/effective/saved/filesystem identity"));
|
||||
}
|
||||
}
|
||||
if !field(status, "Groups:")?.trim().is_empty() || field(status, "NoNewPrivs:")?.trim() != "1" {
|
||||
return Err(denied("groups/no_new_privs"));
|
||||
}
|
||||
for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] {
|
||||
if u64::from_str_radix(field(status, name)?.trim(), 16).ok() != Some(0) {
|
||||
return Err(denied("nonempty capability set"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const STATUS: &str = "Uid:\t100000 100000 100000 100000\nGid:\t100001 100001 100001 100001\nGroups:\t\nNoNewPrivs:\t1\nCapInh:\t0000\nCapPrm:\t0000\nCapEff:\t0000\nCapBnd:\t0000\nCapAmb:\t0000\n";
|
||||
#[test]
|
||||
fn readback_rejects_saved_ids_groups_missing_fields_and_any_capability() {
|
||||
verify_status(STATUS, 100000, 100001).unwrap();
|
||||
for invalid in [
|
||||
STATUS.replace("100000 100000 100000 100000", "100000 100000 0 100000"),
|
||||
STATUS.replace("Groups:\t", "Groups:\t0"),
|
||||
STATUS.replace("NoNewPrivs:\t1", "NoNewPrivs:\t0"),
|
||||
STATUS.replace("CapBnd:\t0000", "CapBnd:\t0001"),
|
||||
STATUS.replace("CapAmb:\t0000\n", ""),
|
||||
] {
|
||||
assert!(verify_status(&invalid, 100000, 100001).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Fixed Linux isolation operations; launcher owns process/control orchestration.
|
||||
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod inputs;
|
||||
pub mod namespaces;
|
||||
pub(crate) mod proc_dev;
|
||||
|
||||
@@ -98,7 +98,7 @@ fn safe_directory(fd: &OwnedFd) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn open_directory(path: &Path) -> io::Result<OwnedFd> {
|
||||
pub(crate) fn open_directory(path: &Path) -> io::Result<OwnedFd> {
|
||||
if !path.is_absolute() {
|
||||
return Err(invalid("root capability must be absolute"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Identity ownership is retained through target teardown, including failed cleanup.
|
||||
use super::blocked::{InitializationFailure, failure};
|
||||
use super::inputs::InputBoundTarget;
|
||||
use crate::artifacts::ArtifactRoot;
|
||||
use crate::isolation::root::RootError;
|
||||
use crate::outcome::{ErrorCode, ExecutionStage};
|
||||
use crate::runtime::ValidatedRuntimeConfig;
|
||||
use crate::spec::ValidatedSpec;
|
||||
use rustix::fd::OwnedFd;
|
||||
use rustix::fs::{AtFlags, Mode, OFlags};
|
||||
use std::{io, io::Write, path::Path};
|
||||
|
||||
const DIR: OFlags = OFlags::RDONLY
|
||||
.union(OFlags::DIRECTORY)
|
||||
.union(OFlags::NOFOLLOW)
|
||||
.union(OFlags::CLOEXEC);
|
||||
fn denied(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
fn private(fd: &OwnedFd) -> io::Result<()> {
|
||||
let s = rustix::fs::fstat(fd)?;
|
||||
if s.st_uid != 0 || s.st_gid != 0 || s.st_mode & 0o7777 != 0o700 {
|
||||
return Err(denied("unsafe identity lease directory"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct IdentityLease {
|
||||
parent: OwnedFd,
|
||||
slot: OwnedFd,
|
||||
name: String,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
removed: bool,
|
||||
}
|
||||
impl IdentityLease {
|
||||
fn acquire(config: &ValidatedRuntimeConfig, spec: &ValidatedSpec) -> Result<Self, RootError> {
|
||||
if !rustix::process::geteuid().is_root() {
|
||||
return Err(denied("identity allocator requires privileged supervisor").into());
|
||||
}
|
||||
let state = crate::isolation::root::open_directory(Path::new(&config.config().state_root))?;
|
||||
match rustix::fs::mkdirat(&state, "identities", Mode::from_raw_mode(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let parent = rustix::fs::openat(&state, "identities", DIR, Mode::empty())?;
|
||||
private(&parent)?;
|
||||
for index in 0..config.config().max_concurrent_runs {
|
||||
let (uid, gid) = config
|
||||
.task_identity(index)
|
||||
.ok_or_else(|| denied("identity slot mapping"))?;
|
||||
let name = format!("uid-{uid}");
|
||||
match rustix::fs::mkdirat(&parent, name.as_str(), Mode::from_raw_mode(0o700)) {
|
||||
Err(rustix::io::Errno::EXIST) => continue, // Never adopt a crash/foreign marker.
|
||||
Err(e) => return Err(e.into()),
|
||||
Ok(()) => {}
|
||||
}
|
||||
let slot = match rustix::fs::openat(&parent, name.as_str(), DIR, Mode::empty()) {
|
||||
Ok(fd) => fd,
|
||||
Err(e) => {
|
||||
return Err(RootError {
|
||||
source: e.into(),
|
||||
cleanup_error: rustix::fs::unlinkat(
|
||||
&parent,
|
||||
name.as_str(),
|
||||
AtFlags::REMOVEDIR,
|
||||
)
|
||||
.err()
|
||||
.map(Into::into),
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut lease = Self {
|
||||
parent,
|
||||
slot,
|
||||
name,
|
||||
uid,
|
||||
gid,
|
||||
removed: false,
|
||||
};
|
||||
let setup = (|| {
|
||||
rustix::fs::fchmod(&lease.slot, Mode::from_raw_mode(0o700))?;
|
||||
private(&lease.slot)?;
|
||||
let marker = rustix::fs::openat(
|
||||
&lease.slot,
|
||||
"owner.json",
|
||||
OFlags::WRONLY
|
||||
| OFlags::CREATE
|
||||
| OFlags::EXCL
|
||||
| OFlags::CLOEXEC
|
||||
| OFlags::NOFOLLOW,
|
||||
Mode::from_raw_mode(0o600),
|
||||
)?;
|
||||
let mut marker = std::fs::File::from(marker);
|
||||
serde_json::to_writer(
|
||||
&mut marker,
|
||||
&serde_json::json!({"version":1,"run_id":spec.spec().run_id,"uid":uid,"gid":gid}),
|
||||
)?;
|
||||
marker.write_all(b"\n")?;
|
||||
marker.sync_all()?;
|
||||
rustix::fs::fsync(&lease.slot)?;
|
||||
rustix::fs::fsync(&lease.parent)?;
|
||||
Ok::<_, io::Error>(())
|
||||
})();
|
||||
if let Err(source) = setup {
|
||||
return Err(RootError {
|
||||
source,
|
||||
cleanup_error: lease.release().err(),
|
||||
});
|
||||
}
|
||||
return Ok(lease);
|
||||
}
|
||||
Err(rustix::io::Errno::AGAIN.into())
|
||||
}
|
||||
// There is intentionally no automatic lease release in IdentityLease::Drop.
|
||||
// Only a wrapper that has confirmed complete teardown may remove a marker.
|
||||
fn release(&mut self) -> io::Result<()> {
|
||||
if self.removed {
|
||||
return Ok(());
|
||||
}
|
||||
let named = rustix::fs::openat(&self.parent, self.name.as_str(), DIR, Mode::empty())?;
|
||||
let a = rustix::fs::fstat(&named)?;
|
||||
let b = rustix::fs::fstat(&self.slot)?;
|
||||
if (a.st_dev, a.st_ino) != (b.st_dev, b.st_ino) {
|
||||
return Err(denied("identity marker changed"));
|
||||
}
|
||||
match rustix::fs::unlinkat(&self.slot, "owner.json", AtFlags::empty()) {
|
||||
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
rustix::fs::unlinkat(&self.parent, self.name.as_str(), AtFlags::REMOVEDIR)?;
|
||||
self.removed = true;
|
||||
rustix::fs::fsync(&self.parent)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn release_after_cleanup(lease: &mut IdentityLease, result: &mut InitializationFailure) {
|
||||
if result.cleanup_failure.is_none()
|
||||
&& let Err(e) = lease.release()
|
||||
{
|
||||
result.cleanup_failure = Some(failure(
|
||||
ExecutionStage::Cleanup,
|
||||
ErrorCode::CleanupIncomplete,
|
||||
Some(&e),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdentityReducedTarget {
|
||||
target: Option<InputBoundTarget>,
|
||||
lease: IdentityLease,
|
||||
}
|
||||
impl IdentityReducedTarget {
|
||||
pub fn prepare(
|
||||
config: &ValidatedRuntimeConfig,
|
||||
spec: &ValidatedSpec,
|
||||
executable: &Path,
|
||||
artifacts: &ArtifactRoot,
|
||||
) -> Result<Self, InitializationFailure> {
|
||||
let mut lease =
|
||||
IdentityLease::acquire(config, spec).map_err(|e| InitializationFailure {
|
||||
failure: failure(
|
||||
ExecutionStage::Identity,
|
||||
ErrorCode::OperationFailed,
|
||||
Some(&e.source),
|
||||
),
|
||||
cleanup_failure: e.cleanup_error.as_ref().map(|e| {
|
||||
failure(
|
||||
ExecutionStage::Cleanup,
|
||||
ErrorCode::CleanupIncomplete,
|
||||
Some(e),
|
||||
)
|
||||
}),
|
||||
})?;
|
||||
let mut target = match InputBoundTarget::prepare(config, spec, executable, artifacts) {
|
||||
Ok(target) => target,
|
||||
Err(mut e) => {
|
||||
release_after_cleanup(&mut lease, &mut e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = target.target.root.initialize_identity(lease.uid, lease.gid) {
|
||||
let mut e = target
|
||||
.target
|
||||
.root
|
||||
.refuse_stage(ExecutionStage::Identity, Some(&e));
|
||||
release_after_cleanup(&mut lease, &mut e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(Self {
|
||||
target: Some(target),
|
||||
lease,
|
||||
})
|
||||
}
|
||||
pub fn root(&self) -> &super::root::RootedTarget {
|
||||
self.target.as_ref().expect("owned target").root()
|
||||
}
|
||||
pub fn identity(&self) -> (u32, u32) {
|
||||
(self.lease.uid, self.lease.gid)
|
||||
}
|
||||
pub fn stdin(&self) -> Option<&crate::artifacts::SnapshotFile> {
|
||||
self.target.as_ref().expect("owned target").stdin()
|
||||
}
|
||||
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
|
||||
let target = self.target.take().expect("owned target");
|
||||
let mut result = target
|
||||
.target
|
||||
.root
|
||||
.refuse_stage(ExecutionStage::Seccomp, None);
|
||||
release_after_cleanup(&mut self.lease, &mut result);
|
||||
result
|
||||
}
|
||||
}
|
||||
impl Drop for IdentityReducedTarget {
|
||||
fn drop(&mut self) {
|
||||
if let Some(target) = self.target.take() {
|
||||
let mut result = target
|
||||
.target
|
||||
.root
|
||||
.refuse_stage(ExecutionStage::Seccomp, None);
|
||||
release_after_cleanup(&mut self.lease, &mut result);
|
||||
// An error leaves the durable marker reserved for restart recovery.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn initialize(
|
||||
channel: &mut super::protocol::ControlChannel,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
) -> io::Result<()> {
|
||||
let result = crate::isolation::identity::reduce_init(uid, gid);
|
||||
if let Err(e) = &result {
|
||||
let _ = channel.send(&super::protocol::NamespaceEvent::Failed {
|
||||
errno: e.raw_os_error().and_then(|n| u16::try_from(n).ok()),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
channel.send(&super::protocol::NamespaceEvent::IdentityInitialized {})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod blocked;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod entry;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod identity;
|
||||
pub mod inputs;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod namespaces;
|
||||
|
||||
@@ -189,7 +189,14 @@ pub fn namespace_init(expected_outer_parent: u32, initialize_root: bool) -> io::
|
||||
NamespaceCommand::Cancel {} => return Ok(()),
|
||||
_ => return Err(denied("input command before preparation")),
|
||||
}
|
||||
let _ = channel.receive::<NamespaceCommand>()?;
|
||||
match channel.receive::<NamespaceCommand>()? {
|
||||
NamespaceCommand::PrepareIdentity { uid, gid } => {
|
||||
super::identity::initialize(&mut channel, uid, gid)?;
|
||||
let _ = channel.receive::<NamespaceCommand>()?;
|
||||
}
|
||||
NamespaceCommand::Cancel {} => return Ok(()),
|
||||
_ => return Err(denied("identity command before inputs")),
|
||||
}
|
||||
}
|
||||
NamespaceCommand::Cancel {} => return Ok(()),
|
||||
_ => {}
|
||||
@@ -211,6 +218,7 @@ pub struct NamespacedTarget {
|
||||
root_ready: bool,
|
||||
proc_dev_ready: bool,
|
||||
inputs_ready: bool,
|
||||
identity_ready: bool,
|
||||
init_pid: u32,
|
||||
ids: NamespaceIds,
|
||||
}
|
||||
@@ -304,6 +312,7 @@ impl NamespacedTarget {
|
||||
root_ready: false,
|
||||
proc_dev_ready: false,
|
||||
inputs_ready: false,
|
||||
identity_ready: false,
|
||||
}),
|
||||
Err(error) => Err(prepared.refuse_at(ExecutionStage::Namespaces, Some(&error))),
|
||||
}
|
||||
@@ -378,6 +387,24 @@ impl NamespacedTarget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> {
|
||||
if !self.inputs_ready || self.identity_ready || !crate::isolation::identity::valid(uid, gid)
|
||||
{
|
||||
return Err(denied("credential initialization order"));
|
||||
}
|
||||
self.initialize_filesystem(
|
||||
NamespaceCommand::PrepareIdentity { uid, gid },
|
||||
NamespaceEvent::IdentityInitialized {},
|
||||
)?;
|
||||
crate::isolation::identity::verify_status(
|
||||
&std::fs::read_to_string(format!("/proc/{}/status", self.init_pid))?,
|
||||
uid,
|
||||
gid,
|
||||
)?;
|
||||
self.identity_ready = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_event(&mut self, expected: NamespaceEvent) -> io::Result<()> {
|
||||
match self.control.receive::<NamespaceEvent>()? {
|
||||
event if event == expected => Ok(()),
|
||||
|
||||
@@ -63,6 +63,10 @@ pub enum NamespaceCommand {
|
||||
executable: bool,
|
||||
},
|
||||
FinishInputs {},
|
||||
PrepareIdentity {
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
},
|
||||
Cancel {},
|
||||
}
|
||||
|
||||
@@ -75,6 +79,7 @@ pub enum NamespaceEvent {
|
||||
InputsStarted {},
|
||||
InputAttached {},
|
||||
InputsInitialized {},
|
||||
IdentityInitialized {},
|
||||
Failed { errno: Option<u16> },
|
||||
Cancelled {},
|
||||
}
|
||||
@@ -110,6 +115,7 @@ impl WireMessage for NamespaceCommand {
|
||||
crate::spec::path(path, "input").is_ok()
|
||||
&& *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
|
||||
}
|
||||
Self::PrepareIdentity { uid, gid } => crate::isolation::identity::valid(*uid, *gid),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,22 @@ impl RootedTarget {
|
||||
crate::isolation::inputs::verify(self.init_pid(), &inputs.files)
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_identity(&mut self, uid: u32, gid: u32) -> io::Result<()> {
|
||||
self.target.initialize_identity(uid, gid)?;
|
||||
self.lease.verify_root(self.init_pid())?;
|
||||
self.lease.verify_root(self.bootstrap_pid())
|
||||
}
|
||||
|
||||
pub(crate) fn refuse_stage(
|
||||
mut self,
|
||||
stage: ExecutionStage,
|
||||
error: Option<&io::Error>,
|
||||
) -> InitializationFailure {
|
||||
let mut result = self.target.prepared.refuse_at(stage, error);
|
||||
attach_cleanup(&mut result, self.lease.cleanup().err());
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn refuse_root_error(mut self, error: &io::Error) -> InitializationFailure {
|
||||
let mut failure = self
|
||||
.target
|
||||
@@ -157,7 +173,7 @@ impl RootedTarget {
|
||||
failure
|
||||
}
|
||||
|
||||
/// Restricted proc/dev, identity, capabilities and seccomp are still absent.
|
||||
/// This base type has no proof of later input/identity/seccomp stages.
|
||||
pub fn refuse_remaining_isolation(mut self) -> InitializationFailure {
|
||||
let mut failure = self
|
||||
.target
|
||||
|
||||
@@ -148,6 +148,12 @@ impl RuntimeConfig {
|
||||
{
|
||||
return Err(invalid("roots", "invalid_layout"));
|
||||
}
|
||||
let identity_root = format!("{}/identities", self.state_root);
|
||||
if [&self.tasks_root, &self.image_root].iter().any(|root| {
|
||||
*root == &identity_root || inside(root, &identity_root) || inside(&identity_root, root)
|
||||
}) {
|
||||
return Err(invalid("roots", "identity_lease_overlap"));
|
||||
}
|
||||
if !inside(&self.delegated_cgroup_root, "/sys/fs/cgroup") {
|
||||
return Err(invalid("delegated_cgroup_root", "not_delegated_subtree"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* VM-only kernel failure injector. It execs one fixed trusted test case. */
|
||||
#define _GNU_SOURCE
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <errno.h>
|
||||
#include <stddef.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
int main(void) {
|
||||
const char *vm=getenv("KF_SANDBOX_INDEPENDENT_VM");
|
||||
if(!vm||vm[0]!='1'||vm[1]||access("/kf/INDEPENDENT_VM",R_OK))return 2;
|
||||
struct sock_filter filter[]={
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,arch)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,AUDIT_ARCH_X86_64,1,0),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_KILL_PROCESS),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS,offsetof(struct seccomp_data,nr)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K,__NR_setgroups,0,1),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ERRNO|EPERM),
|
||||
BPF_STMT(BPF_RET|BPF_K,SECCOMP_RET_ALLOW)
|
||||
};
|
||||
struct sock_fprog program={.len=sizeof filter/sizeof *filter,.filter=filter};
|
||||
if(prctl(PR_SET_NO_NEW_PRIVS,1,0,0,0)||prctl(PR_SET_SECCOMP,SECCOMP_MODE_FILTER,&program))return 3;
|
||||
setenv("KF_SANDBOX_DENY_SETGROUPS","1",1);
|
||||
execl("/kf/bin/identity-vm-tests","identity-vm-tests","--ignored","--test-threads=1","--exact","kernel_credential_failure_refuses_and_releases_own_lease","--nocapture",(char*)0);
|
||||
return 4;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <linux/capability.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/ptrace.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
static int check(void) {
|
||||
uid_t r,e,s; gid_t gr,ge,gs;
|
||||
if(getresuid(&r,&e,&s)||getresgid(&gr,&ge,&gs)) return 1;
|
||||
if(r!=100000||e!=r||s!=r||gr!=r||ge!=r||gs!=r||getgroups(0,NULL)!=0) return 2;
|
||||
struct __user_cap_header_struct h={_LINUX_CAPABILITY_VERSION_3,0};
|
||||
struct __user_cap_data_struct d[2]={{0}};
|
||||
if(syscall(SYS_capget,&h,d)) return 3;
|
||||
for(int i=0;i<2;i++) if(d[i].effective||d[i].permitted||d[i].inheritable) return 4;
|
||||
if(prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)!=1||prctl(PR_GET_SECUREBITS,0,0,0,0)!=0xeb) return 5;
|
||||
for(int cap=0;cap<64;cap++) {
|
||||
int b=prctl(PR_CAPBSET_READ,cap,0,0,0);
|
||||
if(b==-1 && errno==EINVAL) break;
|
||||
if(b!=0||prctl(PR_CAP_AMBIENT,PR_CAP_AMBIENT_IS_SET,cap,0,0)!=0) return 6;
|
||||
}
|
||||
if(setuid(0)!=-1||errno!=EPERM||setgid(0)!=-1||errno!=EPERM) return 7;
|
||||
gid_t group=0;
|
||||
if(setgroups(1,&group)!=-1||errno!=EPERM) return 8;
|
||||
if(mount("tmpfs","/tmp", "tmpfs",0,NULL)!=-1||errno!=EPERM) return 9;
|
||||
d[0].permitted=d[0].effective=1U<<CAP_SYS_ADMIN;
|
||||
if(syscall(SYS_capset,&h,d)!=-1||errno!=EPERM) return 10;
|
||||
if(prctl(PR_SET_SECUREBITS,0,0,0,0)!=-1||errno!=EPERM) return 11;
|
||||
return 0;
|
||||
}
|
||||
static void *thread_check(void *unused) { (void)unused; return (void *)(long)check(); }
|
||||
int main(int argc,char **argv) {
|
||||
if(argc==2 && !strcmp(argv[1],"--suid-control-child")) {
|
||||
if(access("/kf/INDEPENDENT_VM",R_OK)||geteuid()!=0||prctl(PR_GET_NO_NEW_PRIVS,0,0,0,0)!=0) return 30;
|
||||
if(setgroups(0,NULL)||setresgid(100000,100000,100000)||setresuid(100000,100000,100000))return 31;
|
||||
execl("/kf/bin/identity-suid-probe","identity-suid-probe","--suid-positive",(char *)0);return 32;
|
||||
}
|
||||
if(argc==2 && !strcmp(argv[1],"--suid-positive")) {
|
||||
if(getuid()!=100000||geteuid()!=0) return 33;
|
||||
puts("KF_SUID_CONTROL_PROOF works_without_nnp=1");return 0;
|
||||
}
|
||||
int result=check(); if(result){fprintf(stderr,"credential check=%d\n",result);return result;}
|
||||
pthread_t thread; void *thread_result;
|
||||
if(pthread_create(&thread,NULL,thread_check,NULL)||pthread_join(thread,&thread_result)||thread_result) return 20;
|
||||
pid_t child=fork();if(child<0)return 21;if(!child)_exit(check());
|
||||
int status;if(waitpid(child,&status,0)!=child||!WIFEXITED(status)||WEXITSTATUS(status))return 22;
|
||||
if(argc==2) {
|
||||
pid_t peer=(pid_t)strtol(argv[1],NULL,10); if(peer<=1)return 23;
|
||||
if(kill(peer,0)!=-1||errno!=EPERM)return 24;
|
||||
if(ptrace(PTRACE_ATTACH,peer,NULL,NULL)!=-1||errno!=EPERM)return 25;
|
||||
char path[64];snprintf(path,sizeof path,"/proc/%d/root/work/main",peer);
|
||||
int fd=open(path,O_RDONLY|O_CLOEXEC);if(fd>=0){close(fd);return 26;}
|
||||
if(errno!=EACCES&&errno!=EPERM)return 27;
|
||||
} else if(argc!=1) return 28;
|
||||
puts("KF_IDENTITY_INHERIT_PROBE ids=1 groups=0 caps=0 nnp=1 securebits=1 escalation_denied=1 fork_thread_inherit=1 peer_denied=1");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
mod support;
|
||||
use kf_sandbox::artifacts::ArtifactRoot;
|
||||
use kf_sandbox::launcher::identity::IdentityReducedTarget;
|
||||
use kf_sandbox::outcome::ExecutionStage;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use support::{Root, binary, children};
|
||||
const LEASES: &str = "/var/lib/kf-judge/identities";
|
||||
struct Publications(PathBuf);
|
||||
impl Publications {
|
||||
fn new() -> Self {
|
||||
support::require_vm();
|
||||
let path = PathBuf::from(format!("/kf/identity-publications-{}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(path.join("program-001"), b"fixed-non-task-payload").unwrap();
|
||||
fs::write(path.join("input-001"), b"private-stdin").unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn root(&self) -> ArtifactRoot {
|
||||
ArtifactRoot::from_directory(
|
||||
rustix::fs::open(
|
||||
&self.0,
|
||||
rustix::fs::OFlags::PATH
|
||||
| rustix::fs::OFlags::DIRECTORY
|
||||
| rustix::fs::OFlags::CLOEXEC,
|
||||
rustix::fs::Mode::empty(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Publications {
|
||||
fn drop(&mut self) {
|
||||
fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
fn prepare(
|
||||
root: &Root,
|
||||
publications: &Publications,
|
||||
id: &str,
|
||||
) -> Result<IdentityReducedTarget, kf_sandbox::launcher::blocked::InitializationFailure> {
|
||||
IdentityReducedTarget::prepare(
|
||||
&root.validated(),
|
||||
&support::spec(id, 64 << 20, 8),
|
||||
&binary(),
|
||||
&publications.root(),
|
||||
)
|
||||
}
|
||||
fn leases() -> Vec<String> {
|
||||
let mut result: Vec<_> = fs::read_dir(LEASES)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
result.sort();
|
||||
result
|
||||
}
|
||||
fn verify_ids(target: &IdentityReducedTarget) {
|
||||
let (uid, gid) = target.identity();
|
||||
let status = fs::read_to_string(format!("/proc/{}/status", target.root().init_pid())).unwrap();
|
||||
for (name, id) in [("Uid:", uid), ("Gid:", gid)] {
|
||||
let line = status.lines().find(|l| l.starts_with(name)).unwrap();
|
||||
assert_eq!(
|
||||
line.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.parse::<u32>().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![id; 4]
|
||||
);
|
||||
}
|
||||
for name in ["CapInh:", "CapPrm:", "CapEff:", "CapBnd:", "CapAmb:"] {
|
||||
assert_eq!(
|
||||
u64::from_str_radix(
|
||||
status
|
||||
.lines()
|
||||
.find(|l| l.starts_with(name))
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap(),
|
||||
16
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
status
|
||||
.lines()
|
||||
.find(|l| l.starts_with("Groups:"))
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(status.contains("NoNewPrivs:\t1\n"));
|
||||
assert!(target.root().snapshot_outputs().unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM and fixed credential inheritance fixture"]
|
||||
fn production_init_reduces_credentials_and_leases_distinct_ids_until_cleanup() {
|
||||
let root = Root::new();
|
||||
let publications = Publications::new();
|
||||
let before = children();
|
||||
let one = prepare(&root, &publications, "identity-one").unwrap();
|
||||
let two = prepare(&root, &publications, "identity-two").unwrap();
|
||||
assert_eq!(one.identity(), (100000, 100000));
|
||||
assert_eq!(two.identity(), (100001, 100001));
|
||||
verify_ids(&one);
|
||||
verify_ids(&two);
|
||||
assert_eq!(leases(), ["uid-100000", "uid-100001"]);
|
||||
let Err(busy) = prepare(&root, &publications, "identity-busy") else {
|
||||
panic!("live identity reused");
|
||||
};
|
||||
assert_eq!(busy.failure.stage, ExecutionStage::Identity);
|
||||
assert_eq!(busy.failure.errno, Some(11));
|
||||
assert!(busy.cleanup_failure.is_none());
|
||||
let output = Command::new("/kf/bin/identity-probe")
|
||||
.args(["fixed-probe", &two.root().init_pid().to_string()])
|
||||
.env_clear()
|
||||
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{:?} {:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let proof = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(proof.contains("KF_IDENTITY_REDUCER_PROOF"));
|
||||
print!("{proof}");
|
||||
let result = one.refuse_remaining_isolation();
|
||||
assert_eq!(result.failure.stage, ExecutionStage::Seccomp);
|
||||
assert!(result.cleanup_failure.is_none());
|
||||
let reuse = prepare(&root, &publications, "identity-reuse").unwrap();
|
||||
assert_eq!(reuse.identity(), (100000, 100000));
|
||||
verify_ids(&reuse);
|
||||
drop(reuse);
|
||||
drop(two);
|
||||
assert!(leases().is_empty());
|
||||
assert_eq!(children(), before);
|
||||
for id in [
|
||||
"identity-one",
|
||||
"identity-two",
|
||||
"identity-busy",
|
||||
"identity-reuse",
|
||||
] {
|
||||
assert!(!root.path.join(format!("kf-{id}")).exists());
|
||||
assert!(!Path::new(&format!("/var/lib/kf-judge/tasks/kf-{id}")).exists());
|
||||
}
|
||||
println!(
|
||||
"KF_IDENTITY_PROOF all_four_ids=1 groups=0 five_cap_sets=0 nnp=1 distinct_leases=1 exhaustion_refused=1 release_after_cleanup=1 safe_reuse=1 drop_cleanup=1 fixed_fixture_not_user_exec=1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM"]
|
||||
fn foreign_markers_and_unsafe_lease_roots_are_not_adopted() {
|
||||
let root = Root::new();
|
||||
let publications = Publications::new();
|
||||
let before = children();
|
||||
fs::create_dir_all(LEASES).unwrap();
|
||||
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let foreign = Path::new(LEASES).join("uid-100000");
|
||||
fs::create_dir(&foreign).unwrap();
|
||||
fs::set_permissions(&foreign, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(foreign.join("foreign"), b"must retain").unwrap();
|
||||
let target = prepare(&root, &publications, "identity-foreign").unwrap();
|
||||
assert_eq!(target.identity(), (100001, 100001));
|
||||
drop(target);
|
||||
assert_eq!(fs::read(foreign.join("foreign")).unwrap(), b"must retain");
|
||||
fs::remove_file(foreign.join("foreign")).unwrap();
|
||||
fs::remove_dir(&foreign).unwrap();
|
||||
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
let Err(error) = prepare(&root, &publications, "identity-unsafe") else {
|
||||
panic!("writable lease root accepted");
|
||||
};
|
||||
assert_eq!(error.failure.stage, ExecutionStage::Identity);
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert!(leases().is_empty());
|
||||
fs::set_permissions(LEASES, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::remove_file(publications.0.join("program-001")).unwrap();
|
||||
let Err(error) = prepare(&root, &publications, "identity-bad-input") else {
|
||||
panic!("missing input accepted");
|
||||
};
|
||||
assert_eq!(error.failure.stage, ExecutionStage::Preparation);
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert!(leases().is_empty());
|
||||
assert_eq!(children(), before);
|
||||
println!(
|
||||
"KF_IDENTITY_LEASE_POLICY_PROOF foreign_retained=1 writable_root_refused=1 failed_preparation_slot_released=1 no_target_leftover=1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM; cleanup failure injection"]
|
||||
fn incomplete_target_cleanup_keeps_identity_reserved() {
|
||||
let root = Root::new();
|
||||
let publications = Publications::new();
|
||||
let before = children();
|
||||
let target = prepare(&root, &publications, "identity-cleanup-failure").unwrap();
|
||||
let task = Path::new("/var/lib/kf-judge/tasks/kf-identity-cleanup-failure");
|
||||
fs::write(task.join("foreign"), b"keep").unwrap();
|
||||
let error = target.refuse_remaining_isolation();
|
||||
assert_eq!(error.failure.stage, ExecutionStage::Seccomp);
|
||||
assert!(error.cleanup_failure.is_some());
|
||||
assert_eq!(leases(), ["uid-100000"]);
|
||||
assert!(!root.path.join("kf-identity-cleanup-failure").exists());
|
||||
assert_eq!(children(), before);
|
||||
let next = prepare(&root, &publications, "identity-after-failed-cleanup").unwrap();
|
||||
assert_eq!(next.identity(), (100001, 100001));
|
||||
drop(next);
|
||||
assert_eq!(fs::read(task.join("foreign")).unwrap(), b"keep");
|
||||
// VM operator restores the test baseline only after proving both task
|
||||
// cgroups/children gone. Production restart recovery is still unimplemented.
|
||||
fs::remove_file(task.join("foreign")).unwrap();
|
||||
fs::remove_dir(task).unwrap();
|
||||
fs::remove_file(Path::new(LEASES).join("uid-100000/owner.json")).unwrap();
|
||||
fs::remove_dir(Path::new(LEASES).join("uid-100000")).unwrap();
|
||||
assert!(leases().is_empty());
|
||||
println!(
|
||||
"KF_IDENTITY_CLEANUP_PROOF cleanup_error_separate=1 failed_slot_reserved=1 alternate_slot_only=1 foreign_not_deleted=1 test_baseline_operator_restore=1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires independent capped VM with kernel setgroups failure injector"]
|
||||
fn kernel_credential_failure_refuses_and_releases_own_lease() {
|
||||
let root = Root::new();
|
||||
assert_eq!(
|
||||
std::env::var("KF_SANDBOX_DENY_SETGROUPS").as_deref(),
|
||||
Ok("1")
|
||||
);
|
||||
let publications = Publications::new();
|
||||
let before = children();
|
||||
let Err(error) = prepare(&root, &publications, "identity-kernel-denied") else {
|
||||
panic!("credential error ignored");
|
||||
};
|
||||
assert_eq!(error.failure.stage, ExecutionStage::Identity);
|
||||
assert_eq!(error.failure.errno, Some(1));
|
||||
assert!(error.cleanup_failure.is_none());
|
||||
assert!(leases().is_empty());
|
||||
assert_eq!(children(), before);
|
||||
assert!(!root.path.join("kf-identity-kernel-denied").exists());
|
||||
assert!(!Path::new("/var/lib/kf-judge/tasks/kf-identity-kernel-denied").exists());
|
||||
println!(
|
||||
"KF_IDENTITY_FAILURE_PROOF real_kernel_eperm=1 identity_ack_refused=1 slot_released_after_teardown=1 child_reaped=1 task_cleanup=1"
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ fn roots_reject_traversal_aliases_system_locations_and_overlapping_storage() {
|
||||
for path in [
|
||||
"/var/lib/kf-judge/tasks",
|
||||
"/var/lib/kf-judge/tasks/image",
|
||||
"/var/lib/kf-judge/identities",
|
||||
"/var/lib/kf-judge/identities/image",
|
||||
"/var/lib/kf-judge",
|
||||
"/srv/unrelated",
|
||||
] {
|
||||
|
||||
Reference in New Issue
Block a user