mirror of
https://github.com/openai/codex.git
synced 2026-04-30 19:32:04 +03:00
## Summary - Use `std::env::split_paths` to parse PATH entries in audit candidate collection - Add a unit test covering multiple PATH entries (including spaces) ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9317
340 lines
12 KiB
Rust
340 lines
12 KiB
Rust
use crate::acl::add_deny_write_ace;
|
|
use crate::acl::path_mask_allows;
|
|
use crate::cap::cap_sid_file;
|
|
use crate::cap::load_or_create_cap_sids;
|
|
use crate::logging::{debug_log, log_note};
|
|
use crate::policy::SandboxPolicy;
|
|
use crate::token::convert_string_sid_to_sid;
|
|
use crate::token::world_sid;
|
|
use anyhow::anyhow;
|
|
use anyhow::Result;
|
|
use std::collections::HashSet;
|
|
use std::ffi::c_void;
|
|
use std::ffi::OsStr;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA;
|
|
use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA;
|
|
|
|
// Preflight scan limits
|
|
const MAX_ITEMS_PER_DIR: i32 = 1000;
|
|
const AUDIT_TIME_LIMIT_SECS: i64 = 2;
|
|
const MAX_CHECKED_LIMIT: i32 = 50000;
|
|
// Case-insensitive suffixes (normalized to forward slashes) to skip during one-level child scan
|
|
const SKIP_DIR_SUFFIXES: &[&str] = &[
|
|
"/windows/installer",
|
|
"/windows/registration",
|
|
"/programdata",
|
|
];
|
|
|
|
pub(crate) fn normalize_path_key(p: &Path) -> String {
|
|
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
|
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
|
|
}
|
|
|
|
fn unique_push(set: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>, p: PathBuf) {
|
|
if let Ok(abs) = p.canonicalize() {
|
|
if set.insert(abs.clone()) {
|
|
out.push(abs);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn gather_candidates(cwd: &Path, env: &std::collections::HashMap<String, String>) -> Vec<PathBuf> {
|
|
let mut set: HashSet<PathBuf> = HashSet::new();
|
|
let mut out: Vec<PathBuf> = Vec::new();
|
|
// 1) CWD first (so immediate children get scanned early)
|
|
unique_push(&mut set, &mut out, cwd.to_path_buf());
|
|
// 2) TEMP/TMP next (often small, quick to scan)
|
|
for k in ["TEMP", "TMP"] {
|
|
if let Some(v) = env.get(k).cloned().or_else(|| std::env::var(k).ok()) {
|
|
unique_push(&mut set, &mut out, PathBuf::from(v));
|
|
}
|
|
}
|
|
// 3) User roots
|
|
if let Some(up) = std::env::var_os("USERPROFILE") {
|
|
unique_push(&mut set, &mut out, PathBuf::from(up));
|
|
}
|
|
if let Some(pubp) = std::env::var_os("PUBLIC") {
|
|
unique_push(&mut set, &mut out, PathBuf::from(pubp));
|
|
}
|
|
// 4) PATH entries (best-effort)
|
|
if let Some(path) = env
|
|
.get("PATH")
|
|
.cloned()
|
|
.or_else(|| std::env::var("PATH").ok())
|
|
{
|
|
for part in std::env::split_paths(OsStr::new(&path)) {
|
|
if !part.as_os_str().is_empty() {
|
|
unique_push(&mut set, &mut out, part);
|
|
}
|
|
}
|
|
}
|
|
// 5) Core system roots last
|
|
for p in [PathBuf::from("C:/"), PathBuf::from("C:/Windows")] {
|
|
unique_push(&mut set, &mut out, p);
|
|
}
|
|
out
|
|
}
|
|
|
|
unsafe fn path_has_world_write_allow(path: &Path) -> Result<bool> {
|
|
let mut world = world_sid()?;
|
|
let psid_world = world.as_mut_ptr() as *mut c_void;
|
|
let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES;
|
|
path_mask_allows(path, &[psid_world], write_mask, false)
|
|
}
|
|
|
|
pub fn audit_everyone_writable(
|
|
cwd: &Path,
|
|
env: &std::collections::HashMap<String, String>,
|
|
logs_base_dir: Option<&Path>,
|
|
) -> Result<Vec<PathBuf>> {
|
|
let start = Instant::now();
|
|
let mut flagged: Vec<PathBuf> = Vec::new();
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut checked = 0usize;
|
|
let check_world_writable = |path: &Path| -> bool {
|
|
match unsafe { path_has_world_write_allow(path) } {
|
|
Ok(has) => has,
|
|
Err(err) => {
|
|
debug_log(
|
|
&format!(
|
|
"AUDIT: treating unreadable ACL as not world-writable: {} ({err})",
|
|
path.display()
|
|
),
|
|
logs_base_dir,
|
|
);
|
|
false
|
|
}
|
|
}
|
|
};
|
|
// Fast path: check CWD immediate children first so workspace issues are caught early.
|
|
if let Ok(read) = std::fs::read_dir(cwd) {
|
|
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
|
|
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
|
|| checked > MAX_CHECKED_LIMIT as usize
|
|
{
|
|
break;
|
|
}
|
|
let ft = match ent.file_type() {
|
|
Ok(ft) => ft,
|
|
Err(_) => continue,
|
|
};
|
|
if ft.is_symlink() || !ft.is_dir() {
|
|
continue;
|
|
}
|
|
let p = ent.path();
|
|
checked += 1;
|
|
let has = check_world_writable(&p);
|
|
if has {
|
|
let key = normalize_path_key(&p);
|
|
if seen.insert(key) {
|
|
flagged.push(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Continue with broader candidate sweep
|
|
let candidates = gather_candidates(cwd, env);
|
|
for root in candidates {
|
|
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
|
|| checked > MAX_CHECKED_LIMIT as usize
|
|
{
|
|
break;
|
|
}
|
|
checked += 1;
|
|
let has_root = check_world_writable(&root);
|
|
if has_root {
|
|
let key = normalize_path_key(&root);
|
|
if seen.insert(key) {
|
|
flagged.push(root.clone());
|
|
}
|
|
}
|
|
// one level down best-effort
|
|
if let Ok(read) = std::fs::read_dir(&root) {
|
|
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
|
|
let p = ent.path();
|
|
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
|
|| checked > MAX_CHECKED_LIMIT as usize
|
|
{
|
|
break;
|
|
}
|
|
// Skip reparse points (symlinks/junctions) to avoid auditing link ACLs
|
|
let ft = match ent.file_type() {
|
|
Ok(ft) => ft,
|
|
Err(_) => continue,
|
|
};
|
|
if ft.is_symlink() {
|
|
continue;
|
|
}
|
|
// Skip noisy/irrelevant Windows system subdirectories
|
|
let pl = p.to_string_lossy().to_ascii_lowercase();
|
|
let norm = pl.replace('\\', "/");
|
|
if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) {
|
|
continue;
|
|
}
|
|
if ft.is_dir() {
|
|
checked += 1;
|
|
let has_child = check_world_writable(&p);
|
|
if has_child {
|
|
let key = normalize_path_key(&p);
|
|
if seen.insert(key) {
|
|
flagged.push(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let elapsed_ms = start.elapsed().as_millis();
|
|
if !flagged.is_empty() {
|
|
let mut list = String::new();
|
|
for p in &flagged {
|
|
list.push_str(&format!("\n - {}", p.display()));
|
|
}
|
|
crate::logging::log_note(
|
|
&format!(
|
|
"AUDIT: world-writable scan FAILED; cwd={cwd:?}; checked={checked}; duration_ms={elapsed_ms}; flagged:{}",
|
|
list
|
|
),
|
|
logs_base_dir,
|
|
);
|
|
|
|
return Ok(flagged);
|
|
}
|
|
// Log success once if nothing flagged
|
|
crate::logging::log_note(
|
|
&format!("AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}"),
|
|
logs_base_dir,
|
|
);
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
pub fn apply_world_writable_scan_and_denies(
|
|
codex_home: &Path,
|
|
cwd: &Path,
|
|
env_map: &std::collections::HashMap<String, String>,
|
|
sandbox_policy: &SandboxPolicy,
|
|
logs_base_dir: Option<&Path>,
|
|
) -> Result<()> {
|
|
let flagged = audit_everyone_writable(cwd, env_map, logs_base_dir)?;
|
|
if flagged.is_empty() {
|
|
return Ok(());
|
|
}
|
|
if let Err(err) = apply_capability_denies_for_world_writable(
|
|
codex_home,
|
|
&flagged,
|
|
sandbox_policy,
|
|
cwd,
|
|
logs_base_dir,
|
|
) {
|
|
log_note(
|
|
&format!("AUDIT: failed to apply capability deny ACEs: {}", err),
|
|
logs_base_dir,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn apply_capability_denies_for_world_writable(
|
|
codex_home: &Path,
|
|
flagged: &[PathBuf],
|
|
sandbox_policy: &SandboxPolicy,
|
|
cwd: &Path,
|
|
logs_base_dir: Option<&Path>,
|
|
) -> Result<()> {
|
|
if flagged.is_empty() {
|
|
return Ok(());
|
|
}
|
|
std::fs::create_dir_all(codex_home)?;
|
|
let cap_path = cap_sid_file(codex_home);
|
|
let caps = load_or_create_cap_sids(codex_home)?;
|
|
std::fs::write(&cap_path, serde_json::to_string(&caps)?)?;
|
|
let (active_sid, workspace_roots): (*mut c_void, Vec<PathBuf>) = match sandbox_policy {
|
|
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
|
let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) }
|
|
.ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?;
|
|
let mut roots: Vec<PathBuf> =
|
|
vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())];
|
|
for root in writable_roots {
|
|
let candidate = root.as_path();
|
|
roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf()));
|
|
}
|
|
(sid, roots)
|
|
}
|
|
SandboxPolicy::ReadOnly => (
|
|
unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| {
|
|
anyhow!("ConvertStringSidToSidW failed for readonly capability")
|
|
})?,
|
|
Vec::new(),
|
|
),
|
|
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
|
return Ok(());
|
|
}
|
|
};
|
|
for path in flagged {
|
|
if workspace_roots.iter().any(|root| path.starts_with(root)) {
|
|
continue;
|
|
}
|
|
let res = unsafe { add_deny_write_ace(path, active_sid) };
|
|
match res {
|
|
Ok(true) => log_note(
|
|
&format!("AUDIT: applied capability deny ACE to {}", path.display()),
|
|
logs_base_dir,
|
|
),
|
|
Ok(false) => {}
|
|
Err(err) => log_note(
|
|
&format!(
|
|
"AUDIT: failed to apply capability deny ACE to {}: {}",
|
|
path.display(),
|
|
err
|
|
),
|
|
logs_base_dir,
|
|
),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::gather_candidates;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn gathers_path_entries_by_list_separator() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let dir_a = tmp.path().join("Tools");
|
|
let dir_b = tmp.path().join("Bin");
|
|
let dir_space = tmp.path().join("Program Files");
|
|
fs::create_dir_all(&dir_a).expect("dir a");
|
|
fs::create_dir_all(&dir_b).expect("dir b");
|
|
fs::create_dir_all(&dir_space).expect("dir space");
|
|
|
|
let mut env_map = HashMap::new();
|
|
env_map.insert(
|
|
"PATH".to_string(),
|
|
format!(
|
|
"{};{};{}",
|
|
dir_a.display(),
|
|
dir_b.display(),
|
|
dir_space.display()
|
|
),
|
|
);
|
|
|
|
let candidates = gather_candidates(tmp.path(), &env_map);
|
|
let canon_a = dir_a.canonicalize().expect("canon a");
|
|
let canon_b = dir_b.canonicalize().expect("canon b");
|
|
let canon_space = dir_space.canonicalize().expect("canon space");
|
|
|
|
assert!(candidates.contains(&canon_a));
|
|
assert!(candidates.contains(&canon_b));
|
|
assert!(candidates.contains(&canon_space));
|
|
}
|
|
}
|