Merge branch 'main' into pr-484-clean
This commit is contained in:
commit
ee05d62ce4
90 changed files with 6937 additions and 1403 deletions
|
|
@ -81,14 +81,17 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn bubblewrap_sandbox_name() {
|
||||
assert_eq!(BubblewrapSandbox.name(), "bubblewrap");
|
||||
let sandbox = BubblewrapSandbox;
|
||||
assert_eq!(sandbox.name(), "bubblewrap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubblewrap_is_available_only_if_installed() {
|
||||
// Result depends on whether bwrap is installed
|
||||
let available = BubblewrapSandbox::is_available();
|
||||
let sandbox = BubblewrapSandbox;
|
||||
let _available = sandbox.is_available();
|
||||
|
||||
// Either way, the name should still work
|
||||
assert_eq!(BubblewrapSandbox.name(), "bubblewrap");
|
||||
assert_eq!(sandbox.name(), "bubblewrap");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ fn generate_token() -> String {
|
|||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
format!("zc_{}", hex::encode(&bytes))
|
||||
format!("zc_{}", hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// SHA-256 hash a bearer token for storage. Returns lowercase hex.
|
||||
|
|
|
|||
|
|
@ -343,6 +343,7 @@ impl SecurityPolicy {
|
|||
/// validates each sub-command against the allowlist
|
||||
/// - Blocks single `&` background chaining (`&&` remains supported)
|
||||
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
|
||||
/// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
|
||||
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||||
if self.autonomy == AutonomyLevel::ReadOnly {
|
||||
return false;
|
||||
|
|
@ -350,7 +351,12 @@ impl SecurityPolicy {
|
|||
|
||||
// Block subshell/expansion operators — these allow hiding arbitrary
|
||||
// commands inside an allowed command (e.g. `echo $(rm -rf /)`)
|
||||
if command.contains('`') || command.contains("$(") || command.contains("${") {
|
||||
if command.contains('`')
|
||||
|| command.contains("$(")
|
||||
|| command.contains("${")
|
||||
|| command.contains("<(")
|
||||
|| command.contains(">(")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -359,6 +365,15 @@ impl SecurityPolicy {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Block `tee` — it can write to arbitrary files, bypassing the
|
||||
// redirect check above (e.g. `echo secret | tee /etc/crontab`)
|
||||
if command
|
||||
.split_whitespace()
|
||||
.any(|w| w == "tee" || w.ends_with("/tee"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block background command chaining (`&`), which can hide extra
|
||||
// sub-commands and outlive timeout expectations. Keep `&&` allowed.
|
||||
if contains_single_ampersand(command) {
|
||||
|
|
@ -384,13 +399,9 @@ impl SecurityPolicy {
|
|||
// Strip leading env var assignments (e.g. FOO=bar cmd)
|
||||
let cmd_part = skip_env_assignments(segment);
|
||||
|
||||
let base_cmd = cmd_part
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let mut words = cmd_part.split_whitespace();
|
||||
let base_raw = words.next().unwrap_or("");
|
||||
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
|
||||
|
||||
if base_cmd.is_empty() {
|
||||
continue;
|
||||
|
|
@ -403,6 +414,12 @@ impl SecurityPolicy {
|
|||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate arguments for the command
|
||||
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
|
||||
if !self.is_args_safe(base_cmd, &args) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// At least one command must be present
|
||||
|
|
@ -414,6 +431,29 @@ impl SecurityPolicy {
|
|||
has_cmd
|
||||
}
|
||||
|
||||
/// Check for dangerous arguments that allow sub-command execution.
|
||||
fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
|
||||
let base = base.to_ascii_lowercase();
|
||||
match base.as_str() {
|
||||
"find" => {
|
||||
// find -exec and find -ok allow arbitrary command execution
|
||||
!args.iter().any(|arg| arg == "-exec" || arg == "-ok")
|
||||
}
|
||||
"git" => {
|
||||
// git config, alias, and -c can be used to set dangerous options
|
||||
// (e.g. git config core.editor "rm -rf /")
|
||||
!args.iter().any(|arg| {
|
||||
arg == "config"
|
||||
|| arg.starts_with("config.")
|
||||
|| arg == "alias"
|
||||
|| arg.starts_with("alias.")
|
||||
|| arg == "-c"
|
||||
})
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a file path is allowed (no path traversal, within workspace)
|
||||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||||
// Block null bytes (can truncate paths in C-backed syscalls)
|
||||
|
|
@ -982,12 +1022,43 @@ mod tests {
|
|||
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_argument_injection_blocked() {
|
||||
let p = default_policy();
|
||||
// find -exec is a common bypass
|
||||
assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
|
||||
assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
|
||||
// git config/alias can execute commands
|
||||
assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
|
||||
assert!(!p.is_command_allowed("git alias.st status"));
|
||||
assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
|
||||
// Legitimate commands should still work
|
||||
assert!(p.is_command_allowed("find . -name '*.txt'"));
|
||||
assert!(p.is_command_allowed("git status"));
|
||||
assert!(p.is_command_allowed("git add ."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_dollar_brace_blocked() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_tee_blocked() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_command_allowed("echo secret | tee /etc/crontab"));
|
||||
assert!(!p.is_command_allowed("ls | /usr/bin/tee outfile"));
|
||||
assert!(!p.is_command_allowed("tee file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_process_substitution_blocked() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_command_allowed("cat <(echo pwned)"));
|
||||
assert!(!p.is_command_allowed("ls >(cat /etc/passwd)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_env_var_prefix_with_allowed_cmd() {
|
||||
let p = default_policy();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue