use super::parser::SimpleCommand;
#[derive(Debug, Clone)]
pub enum SemanticResult {
Ok,
Dangerous(String),
TooComplex(String),
}
const EVAL_LIKE_BUILTINS: &[&str] = &[
"eval", "source", ".", "exec", "command", "builtin", "fc", "coproc", "trap", "enable", "mapfile", "readarray", "hash", "bind", "complete", "compgen", "alias", "let", ];
const ZSH_DANGEROUS_BUILTINS: &[&str] = &[
"zmodload",
"emulate",
"sysopen",
"sysread",
"syswrite",
"zpty",
"ztcp",
"zsystem",
"zprof",
"zparseopts",
];
const SAFE_WRAPPERS: &[&str] = &[
"time", "nohup", "timeout", "nice", "env", "stdbuf", "command",
];
const SHELL_ESCAPE_COMMANDS: &[&str] = &[
"eval", "source", ".", "exec", "bash", "sh", "zsh", "dash", "xargs", ];
fn is_valid_duration(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() {
return false;
}
let digit_end = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
if digit_end == 0 {
return false;
}
if digit_end == bytes.len() {
return true; }
matches!(&s[digit_end..], "s" | "m" | "h" | "d")
}
fn is_valid_size(s: &str) -> bool {
if s == "0" {
return true;
}
let bytes = s.as_bytes();
let digit_end = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
if digit_end == 0 {
return false;
}
if digit_end == bytes.len() {
return true;
}
matches!(&s[digit_end..], "K" | "M" | "G")
}
pub(crate) fn strip_safe_wrappers(argv: &[String]) -> Option<Vec<String>> {
let mut result = argv.to_vec();
loop {
if result.is_empty() {
return Some(result);
}
let first = result[0].as_str();
if !SAFE_WRAPPERS.contains(&first) {
return Some(result);
}
match first {
"time" | "nohup" | "command" => {
if result.get(1).map(|s| s.starts_with('-')).unwrap_or(false) {
return None;
}
result.remove(0);
}
"timeout" => {
result.remove(0);
if result.first().map(|s| s.starts_with('-')).unwrap_or(false) {
return None;
}
match result.first() {
Some(dur) if is_valid_duration(dur) => {
result.remove(0);
}
_ => return None,
}
}
"nice" => {
if result.get(1).map(|s| s.starts_with('-')).unwrap_or(false) {
return None;
}
result.remove(0);
}
"env" => {
if result
.get(1)
.map(|s| s.starts_with('-') || s.contains('='))
.unwrap_or(false)
{
return None;
}
result.remove(0);
}
"stdbuf" => {
result.remove(0);
while result
.first()
.map(|s| s.starts_with("-i") || s.starts_with("-o") || s.starts_with("-e"))
.unwrap_or(false)
{
result.remove(0);
if result.first().map(|s| is_valid_size(s)).unwrap_or(false) {
result.remove(0);
} else {
return None;
}
}
}
_ => return None,
}
}
}
fn check_proc_environ(commands: &[SimpleCommand]) -> Option<String> {
for cmd in commands {
for arg in &cmd.argv {
if arg.contains("/proc/") && arg.contains("environ") {
return Some(format!("access to /proc/*/environ detected: {}", arg));
}
}
}
None
}
fn check_jq_system(commands: &[SimpleCommand]) -> Option<String> {
for cmd in commands {
if cmd.argv.first().map(|s| s == "jq").unwrap_or(false) {
for arg in &cmd.argv {
if arg.contains("system(") || arg.contains("system (") {
return Some(format!("jq system() call detected: {}", arg));
}
if arg == "-e" || arg.starts_with("--rawfile") {
return Some(format!("jq dangerous flag: {}", arg));
}
}
}
}
None
}
fn check_interpreter_inline_code(argv: &[String]) -> Option<String> {
let first = argv.first()?;
let base = first.rsplit('/').next().unwrap_or(first);
let is_interpreter = base.starts_with("python")
|| matches!(
base,
"node"
| "nodejs"
| "deno"
| "bun"
| "php"
| "ruby"
| "perl"
| "lua"
| "luajit"
| "Rscript"
| "ir"
);
if !is_interpreter {
return None;
}
let inline = argv
.iter()
.skip(1)
.any(|a| a == "-c" || a == "-e" || a == "--eval" || a.starts_with("--eval="));
if inline {
Some(format!("interpreter inline code execution: {first}"))
} else {
None
}
}
fn is_shell_keyword(s: &str) -> bool {
matches!(
s,
"if" | "then"
| "elif"
| "else"
| "fi"
| "while"
| "until"
| "for"
| "in"
| "do"
| "done"
| "case"
| "esac"
| "function"
| "select"
| "time"
| "{"
| "}"
| "!"
| "[["
)
}
pub fn check_semantics(commands: &[SimpleCommand]) -> SemanticResult {
for cmd in commands {
let stripped = match strip_safe_wrappers(&cmd.argv) {
Some(argv) => argv,
None => {
return SemanticResult::TooComplex(format!(
"failed to strip wrappers: {}",
cmd.argv.join(" ")
));
}
};
if let Some(first) = stripped.first() {
let lower = first.to_lowercase();
if EVAL_LIKE_BUILTINS.contains(&lower.as_str()) {
return SemanticResult::Dangerous(format!(
"dangerous command (eval-like): {}",
first
));
}
if ZSH_DANGEROUS_BUILTINS.contains(&lower.as_str()) {
return SemanticResult::Dangerous(format!("dangerous zsh builtin: {}", first));
}
if SHELL_ESCAPE_COMMANDS.contains(&lower.as_str()) {
return SemanticResult::Dangerous(format!(
"dangerous command (shell escape): {}",
first
));
}
if is_shell_keyword(&lower) {
return SemanticResult::Dangerous(format!("shell keyword as command: {}", first));
}
if let Some(reason) = check_interpreter_inline_code(&stripped) {
return SemanticResult::Dangerous(reason);
}
}
}
if let Some(reason) = check_proc_environ(commands) {
return SemanticResult::Dangerous(reason);
}
if let Some(reason) = check_jq_system(commands) {
return SemanticResult::Dangerous(reason);
}
SemanticResult::Ok
}
#[cfg(test)]
mod tests {
use super::super::parser::{parse_for_security, ParseResult};
use super::*;
fn get_commands(cmd: &str) -> Vec<SimpleCommand> {
match parse_for_security(cmd) {
ParseResult::Simple { commands } => commands,
_ => vec![],
}
}
#[test]
fn test_eval_dangerous() {
let cmds = get_commands("eval 'rm -rf /'");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(r) if r.contains("eval")));
}
#[test]
fn test_source_dangerous() {
let cmds = get_commands("source /tmp/evil.sh");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_dot_dangerous() {
let cmds = get_commands(". /tmp/evil.sh");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_exec_dangerous() {
let cmds = get_commands("exec bash");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_bash_dangerous() {
let cmds = get_commands("bash -c 'rm -rf /'");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_xargs_dangerous() {
let cmds = get_commands("echo /home | xargs rm -rf");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_safe_command() {
let cmds = get_commands("ls -la");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
}
#[test]
fn test_safe_git_status() {
let cmds = get_commands("git status");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
}
#[test]
fn test_safe_compound() {
let cmds = get_commands("ls && git status");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
}
#[test]
fn test_dangerous_in_compound() {
let cmds = get_commands("ls && eval 'rm -rf /'");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_command_builtin() {
let cmds = get_commands("command ls");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
let cmds = get_commands("command -v ls");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::TooComplex(_)));
let cmds = get_commands("command eval 'rm -rf /'");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_trap() {
let cmds = get_commands("trap 'echo hi' INT");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_wrapper_time() {
let cmds = vec![SimpleCommand {
text: "time ls".into(),
argv: vec!["time".into(), "ls".into()],
env_vars: vec![],
redirects: vec![],
}];
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
}
#[test]
fn test_wrapper_timeout() {
let cmds = vec![SimpleCommand {
text: "timeout 10 ls".into(),
argv: vec!["timeout".into(), "10".into(), "ls".into()],
env_vars: vec![],
redirects: vec![],
}];
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Ok));
}
#[test]
fn test_wrapper_unknown_flag() {
let cmds = vec![SimpleCommand {
text: "time -p ls".into(),
argv: vec!["time".into(), "-p".into(), "ls".into()],
env_vars: vec![],
redirects: vec![],
}];
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::TooComplex(_)));
}
#[test]
fn test_proc_environ() {
let cmds = get_commands("cat /proc/1/environ");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(r) if r.contains("/proc")));
}
#[test]
fn test_jq_system() {
let cmds = get_commands("jq 'system(\"id\")'");
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(_)));
}
#[test]
fn test_shell_keyword_as_cmd() {
let cmds = vec![SimpleCommand {
text: "if".into(),
argv: vec!["if".into()],
env_vars: vec![],
redirects: vec![],
}];
let result = check_semantics(&cmds);
assert!(matches!(result, SemanticResult::Dangerous(r) if r.contains("shell keyword")));
}
}