use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SinkArgRole {
Program,
ShellString,
ArgList,
EnvValue,
WorkingDir,
SqlQuery,
HtmlRaw,
UrlTarget,
TemplateString,
NotSink,
}
impl SinkArgRole {
pub fn cwe(&self) -> Option<&'static str> {
match self {
SinkArgRole::Program => Some("CWE-78"),
SinkArgRole::ShellString => Some("CWE-78"),
SinkArgRole::ArgList => Some("CWE-88"), SinkArgRole::SqlQuery => Some("CWE-89"),
SinkArgRole::HtmlRaw => Some("CWE-79"),
SinkArgRole::UrlTarget => Some("CWE-601"),
SinkArgRole::TemplateString => Some("CWE-1336"),
SinkArgRole::EnvValue | SinkArgRole::WorkingDir => None,
SinkArgRole::NotSink => None,
}
}
pub fn severity(&self) -> &'static str {
match self {
SinkArgRole::ShellString => "critical",
SinkArgRole::Program => "critical",
SinkArgRole::SqlQuery => "critical",
SinkArgRole::HtmlRaw => "high",
SinkArgRole::UrlTarget => "high",
SinkArgRole::TemplateString => "high",
SinkArgRole::ArgList => "medium", SinkArgRole::EnvValue => "low",
SinkArgRole::WorkingDir => "low",
SinkArgRole::NotSink => "none",
}
}
pub fn description(&self) -> &'static str {
match self {
SinkArgRole::Program => "executable/binary path",
SinkArgRole::ShellString => "shell command string",
SinkArgRole::ArgList => "command argument",
SinkArgRole::EnvValue => "environment variable",
SinkArgRole::WorkingDir => "working directory",
SinkArgRole::SqlQuery => "SQL query string",
SinkArgRole::HtmlRaw => "raw HTML content",
SinkArgRole::UrlTarget => "URL/redirect target",
SinkArgRole::TemplateString => "template expression",
SinkArgRole::NotSink => "not a sink",
}
}
}
#[derive(Debug, Clone)]
pub struct SinkSite {
pub file: PathBuf,
pub line: usize,
pub function: String,
pub sink_api: String,
pub arg_roles: Vec<(usize, SinkArgRole, bool)>,
pub is_shell_context: bool,
pub tainted_param_name: Option<String>,
}
impl SinkSite {
pub fn has_tainted_dangerous_role(&self) -> Option<(usize, SinkArgRole)> {
for (idx, role, is_constant) in &self.arg_roles {
if !is_constant && role.cwe().is_some() {
return Some((*idx, *role));
}
}
None
}
pub fn is_safe_by_construction(&self) -> bool {
self.arg_roles.iter().all(|(_, role, is_constant)| {
*is_constant || role.cwe().is_none()
})
}
pub fn most_dangerous_tainted_role(&self) -> Option<SinkArgRole> {
let priorities = [
SinkArgRole::ShellString,
SinkArgRole::Program,
SinkArgRole::SqlQuery,
SinkArgRole::HtmlRaw,
SinkArgRole::UrlTarget,
SinkArgRole::TemplateString,
SinkArgRole::ArgList,
];
priorities.into_iter().find(|&role| {
self.arg_roles
.iter()
.any(|(_, r, is_const)| *r == role && !is_const)
})
}
}
pub fn analyze_rust_command(
content: &str,
command_line: usize,
_function_name: &str,
) -> Option<SinkSite> {
let lines: Vec<&str> = content.lines().collect();
if command_line == 0 || command_line > lines.len() {
return None;
}
let start = command_line.saturating_sub(3);
let end = (command_line + 30).min(lines.len());
let mut actual_callsite_line = command_line;
for i in start..end {
if i < lines.len() {
let line_lower = lines[i].to_lowercase();
if line_lower.contains("command::new") {
actual_callsite_line = i + 1; break;
}
}
}
let context: String = lines[start..end].join("\n");
let context_lower = context.to_lowercase();
if !context_lower.contains("command::new") && !context_lower.contains("command::") {
return None;
}
let mut arg_roles = Vec::new();
let mut is_shell_context = false;
let mut tainted_param_name = None;
if let Some(program_match) = extract_command_new_arg(&context) {
let is_constant = is_string_literal(&program_match);
arg_roles.push((0, SinkArgRole::Program, is_constant));
if !is_constant {
let clean_name = program_match
.trim()
.trim_start_matches('&')
.split('.')
.next()
.unwrap_or(&program_match)
.to_string();
tainted_param_name = Some(clean_name);
}
let prog_lower = program_match.to_lowercase();
if prog_lower.contains("sh")
|| prog_lower.contains("bash")
|| prog_lower.contains("cmd")
|| prog_lower.contains("powershell")
{
is_shell_context = true;
}
}
let arg_calls = extract_arg_calls(&context);
for (idx, arg_value) in arg_calls.iter().enumerate() {
let is_constant = is_string_literal(arg_value) || is_array_of_literals(arg_value);
if is_shell_context && (arg_value.contains("-c") || arg_value.contains("/c")) {
if let Some(next) = arg_calls.get(idx + 1) {
let next_is_constant = is_string_literal(next);
arg_roles.push((idx + 2, SinkArgRole::ShellString, next_is_constant));
}
}
arg_roles.push((idx + 1, SinkArgRole::ArgList, is_constant));
}
Some(SinkSite {
file: PathBuf::new(), line: actual_callsite_line, function: String::new(), sink_api: "std::process::Command".to_string(),
arg_roles,
is_shell_context,
tainted_param_name,
})
}
fn extract_command_new_arg(content: &str) -> Option<String> {
let patterns = ["Command::new(", "command::new("];
for pattern in patterns {
if let Some(start) = content.find(pattern) {
let after_paren = &content[start + pattern.len()..];
if let Some(end) = find_matching_paren(after_paren) {
return Some(after_paren[..end].trim().to_string());
}
}
}
None
}
fn extract_arg_calls(content: &str) -> Vec<String> {
let mut results = Vec::new();
let mut remaining = content;
while let Some(pos) = remaining.find(".arg(").or_else(|| remaining.find(".args(")) {
let is_args = remaining[pos..].starts_with(".args(");
let pattern_len = if is_args { 6 } else { 5 };
let after_paren = &remaining[pos + pattern_len..];
if let Some(end) = find_matching_paren(after_paren) {
results.push(after_paren[..end].trim().to_string());
remaining = &after_paren[end..];
} else {
break;
}
}
results
}
fn find_matching_paren(s: &str) -> Option<usize> {
let mut depth = 1;
let mut in_string = false;
let mut escape_next = false;
for (i, c) in s.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match c {
'\\' if in_string => escape_next = true,
'"' => in_string = !in_string,
'(' if !in_string => depth += 1,
')' if !in_string => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
fn is_string_literal(value: &str) -> bool {
let trimmed = value.trim();
(trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
|| (trimmed.starts_with("r#\"") && trimmed.contains("\"#"))
}
fn is_array_of_literals(value: &str) -> bool {
let trimmed = value.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return false;
}
let inner = &trimmed[1..trimmed.len() - 1];
inner.split(',').all(|elem| {
let elem = elem.trim();
is_string_literal(elem) || elem.is_empty()
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SinkVerdict {
Dangerous { role: SinkArgRole, arg_index: usize },
SafeByConstruction,
NotASink,
}
pub fn evaluate_command_sink(site: &SinkSite) -> SinkVerdict {
if site.is_shell_context {
for (idx, role, is_const) in &site.arg_roles {
if *role == SinkArgRole::ShellString && !is_const {
return SinkVerdict::Dangerous {
role: SinkArgRole::ShellString,
arg_index: *idx,
};
}
}
}
for (idx, role, is_const) in &site.arg_roles {
if *role == SinkArgRole::Program && !is_const {
return SinkVerdict::Dangerous {
role: SinkArgRole::Program,
arg_index: *idx,
};
}
}
if site.is_safe_by_construction() {
return SinkVerdict::SafeByConstruction;
}
for (idx, role, is_const) in &site.arg_roles {
if *role == SinkArgRole::ArgList && !is_const {
return SinkVerdict::Dangerous {
role: SinkArgRole::ArgList,
arg_index: *idx,
};
}
}
SinkVerdict::NotASink
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_string_literal() {
assert!(is_string_literal("\"hello\""));
assert!(is_string_literal("'hello'"));
assert!(is_string_literal(" \"hello\" "));
assert!(!is_string_literal("variable"));
assert!(!is_string_literal("func()"));
}
#[test]
fn test_is_array_of_literals() {
assert!(is_array_of_literals("[\"a\", \"b\"]"));
assert!(is_array_of_literals("[\"rev-parse\", \"HEAD\"]"));
assert!(!is_array_of_literals("[variable]"));
assert!(!is_array_of_literals("not_array"));
}
#[test]
fn test_constant_command_is_safe() {
let content = r#"
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
"#;
let site = analyze_rust_command(content, 2, "from_environment").unwrap();
assert!(site.is_safe_by_construction());
assert_eq!(
evaluate_command_sink(&site),
SinkVerdict::SafeByConstruction
);
}
#[test]
fn test_shell_invocation_detected() {
let content = r#"
Command::new("sh")
.arg("-c")
.arg(user_input)
"#;
let site = analyze_rust_command(content, 2, "test").unwrap();
assert!(site.is_shell_context);
}
#[test]
fn test_tainted_program() {
let content = r#"
Command::new(user_provided_binary)
.args(["--version"])
"#;
let site = analyze_rust_command(content, 2, "test").unwrap();
assert!(!site.is_safe_by_construction());
match evaluate_command_sink(&site) {
SinkVerdict::Dangerous {
role: SinkArgRole::Program,
..
} => {}
_ => panic!("Expected Program role to be dangerous"),
}
}
}