fix(security): sandbox the PowerShell module install step under nsjail

The PowerShell module-install step ran `pwsh -Command <install_string>` directly
via start_child_process with no nsjail wrapping, unlike the run step which gates
on is_sandboxing_enabled(). Defense-in-depth: wrap the install step in nsjail
when sandboxing is enabled, using a dedicated install proto modeled on
run.powershell.config.proto but with the module cache mounted rw (so
Save-PSResource can still populate it) and the per-job script/result mounts
dropped.

Smoke-tested locally: nsjail starts, pwsh runs inside the jail (jailed /proc/1),
and writes to the rw cache mount succeed. NOT yet validated end-to-end is module
download from PSGallery / a private repo through the cloud tracing proxy — hence
draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-15 17:03:52 +00:00
parent 36c9f8612b
commit fd54cc694d
2 changed files with 169 additions and 5 deletions
@@ -0,0 +1,123 @@
name: "powershell install"
mode: ONCE
hostname: "powershell"
log_level: ERROR
time_limit: {TIMEOUT}
disable_rl: true
cwd: "/tmp"
clone_newnet: false
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
keep_caps: false
keep_env: true
mount_proc: true
mount {
src: "/bin"
dst: "/bin"
is_bind: true
}
mount {
src: "/proc/self/fd"
dst: "/dev/fd"
is_symlink: true
mandatory: false
}
mount {
src: "/opt/microsoft"
dst: "/opt/microsoft"
is_bind: true
}
mount {
src: "/lib"
dst: "/lib"
is_bind: true
}
mount {
src: "/lib64"
dst: "/lib64"
is_bind: true
mandatory: false
}
mount {
src: "/usr"
dst: "/usr"
is_bind: true
}
mount {
src: "/dev/null"
dst: "/dev/null"
is_bind: true
rw: true
}
{TMP_MOUNT_BLOCK}
mount {
src: "/etc"
dst: "/etc"
is_bind: true
}
# Container runtimes bind exactly these 3 files as separate submounts over
# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable.
# Load-bearing -- do not remove as redundant with the /etc bind above.
mount {
src: "/etc/resolv.conf"
dst: "/etc/resolv.conf"
is_bind: true
mandatory: false
}
mount {
src: "/etc/hosts"
dst: "/etc/hosts"
is_bind: true
mandatory: false
}
mount {
src: "/etc/hostname"
dst: "/etc/hostname"
is_bind: true
mandatory: false
}
mount {
src: "/dev/random"
dst: "/dev/random"
is_bind: true
}
mount {
src: "/dev/urandom"
dst: "/dev/urandom"
is_bind: true
}
iface_no_lo: true
# Writable, unlike the run step: the install step downloads modules into the
# shared PowerShell cache here.
mount {
src: "{CACHE_DIR}"
dst: "{CACHE_DIR}"
is_bind: true
rw: true
mandatory: false
}
envar: "HOME=/tmp"
+46 -5
View File
@@ -18,6 +18,8 @@ use crate::SYSTEM_ROOT;
const NSJAIL_CONFIG_RUN_POWERSHELL_CONTENT: &str =
include_str!("../nsjail/run.powershell.config.proto");
const NSJAIL_CONFIG_INSTALL_POWERSHELL_CONTENT: &str =
include_str!("../nsjail/install.powershell.config.proto");
lazy_static::lazy_static! {
static ref RE_POWERSHELL_IMPORTS: Regex = Regex::new(r#"^\s*Import-Module\s+(?:-Name\s+)?"?([^\s"]+)"?(?:\s+-RequiredVersion\s+"?([^\s"]+)"?)?"#).unwrap();
@@ -563,12 +565,51 @@ pub async fn handle_powershell_job(
&powershell_repo_pat.unwrap_or_default(),
)
.replace("{modules}", &modules_list);
let mut cmd = Command::new(POWERSHELL_PATH.as_str());
cmd.args(&["-Command", &install_string])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Sandbox the install step under nsjail when sandboxing is enabled, matching
// the run step. The install step previously always ran with full worker
// privileges; the install nsjail config mounts the module cache rw so
// Save-PSResource can still write to it.
let install_jailed = is_sandboxing_enabled();
let mut cmd = if install_jailed {
let nsjail_timeout =
resolve_nsjail_timeout(db, &job.workspace_id, job.id, job.timeout).await;
let nsjail_proto = format!("{}.pwsh_install.config.proto", job.id);
let config_content = NSJAIL_CONFIG_INSTALL_POWERSHELL_CONTENT
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout);
write_file(job_dir, &nsjail_proto, &config_content)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
cmd.current_dir(job_dir).args(&[
"--config",
&nsjail_proto,
"--",
POWERSHELL_PATH.as_str(),
"-Command",
&install_string,
]);
cmd
} else {
let mut cmd = Command::new(POWERSHELL_PATH.as_str());
cmd.args(&["-Command", &install_string]);
cmd
};
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let child = start_child_process(cmd, POWERSHELL_PATH.as_str(), false).await?;
let child = start_child_process(
cmd,
if install_jailed {
NSJAIL_PATH.as_str()
} else {
POWERSHELL_PATH.as_str()
},
false,
)
.await?;
handle_child(
&job.id,