use crate::rules::{Rule, create_finding_with_confidence};
use rma_common::{Confidence, Finding, Language, Severity};
use rma_parser::ParsedFile;
use tree_sitter::Node;
pub struct UnsafeBlockRule;
impl Rule for UnsafeBlockRule {
fn id(&self) -> &str {
"rust/unsafe-block"
}
fn description(&self) -> &str {
"Detects unsafe blocks that bypass Rust's safety guarantees"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "unsafe_block", |node: Node| {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Warning,
"Unsafe block bypasses Rust's memory safety - requires manual review",
Language::Rust,
Confidence::High,
));
});
findings
}
}
pub struct TransmuteRule;
impl Rule for TransmuteRule {
fn id(&self) -> &str {
"rust/transmute-used"
}
fn description(&self) -> &str {
"Detects std::mem::transmute which bypasses type safety"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "call_expression", |node: Node| {
if let Some(func) = node.child(0) {
if func.kind() == "scoped_identifier" || func.kind() == "identifier" {
let func_text = func.utf8_text(parsed.content.as_bytes()).unwrap_or("");
if func_text.ends_with("::transmute")
|| func_text.ends_with("::transmute_copy")
|| func_text == "transmute"
|| func_text == "transmute_copy"
{
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Error,
"std::mem::transmute bypasses type safety - ensure this is necessary",
Language::Rust,
Confidence::High,
));
}
}
}
});
findings
}
}
pub struct CommandInjectionRule;
impl CommandInjectionRule {
fn has_injection_pattern(content: &str, start_byte: usize, end_byte: usize) -> bool {
let search_end = (end_byte + 500).min(content.len());
let context = &content[start_byte..search_end];
let has_shell_mode = context.contains("\"-c\"")
|| context.contains("\"/C\"")
|| context.contains("\"-Command\"")
|| context.contains("[\"-c\",")
|| context.contains("[\"/C\",");
let has_dynamic_args = context.contains("format!(")
|| context.contains("&format!(")
|| context.contains(".arg(user")
|| context.contains(".arg(input")
|| context.contains(".arg(cmd")
|| context.contains(".arg(query")
|| context.contains(".args(&")
|| context.contains(".args(vec![");
has_shell_mode && has_dynamic_args
}
}
impl Rule for CommandInjectionRule {
fn id(&self) -> &str {
"rust/command-injection"
}
fn description(&self) -> &str {
"Detects command injection patterns (shell mode with dynamic arguments)"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "call_expression", |node: Node| {
if let Some(func) = node.child(0)
&& let Some(args) = node.child_by_field_name("arguments")
{
let func_text = func.utf8_text(parsed.content.as_bytes()).unwrap_or("");
if func_text.ends_with("Command::new") || func_text == "Command::new" {
let args_text = args.utf8_text(parsed.content.as_bytes()).unwrap_or("");
let is_shell = args_text.contains("\"sh\"")
|| args_text.contains("\"bash\"")
|| args_text.contains("\"/bin/sh\"")
|| args_text.contains("\"/bin/bash\"")
|| args_text.contains("\"cmd\"")
|| args_text.contains("\"powershell\"")
|| args_text.contains("\"cmd.exe\"")
|| args_text.contains("\"powershell.exe\"");
if is_shell
&& Self::has_injection_pattern(
&parsed.content,
node.start_byte(),
node.end_byte(),
)
{
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Critical,
"Command injection: shell mode with dynamic arguments - validate and sanitize input",
Language::Rust,
Confidence::High,
));
}
}
}
});
findings
}
}
pub struct ShellSpawnRule;
impl Rule for ShellSpawnRule {
fn id(&self) -> &str {
"rust/shell-spawn"
}
fn description(&self) -> &str {
"Detects shell process spawning (for security policy awareness)"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "call_expression", |node: Node| {
if let Some(func) = node.child(0)
&& let Some(args) = node.child_by_field_name("arguments")
{
let func_text = func.utf8_text(parsed.content.as_bytes()).unwrap_or("");
if func_text.ends_with("Command::new") || func_text == "Command::new" {
let args_text = args.utf8_text(parsed.content.as_bytes()).unwrap_or("");
let is_shell = args_text.contains("\"sh\"")
|| args_text.contains("\"bash\"")
|| args_text.contains("\"/bin/sh\"")
|| args_text.contains("\"/bin/bash\"")
|| args_text.contains("\"cmd\"")
|| args_text.contains("\"powershell\"");
if is_shell {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Info,
"Shell process spawn - ensure arguments are controlled and expected",
Language::Rust,
Confidence::High,
));
}
}
}
});
findings
}
}
pub struct RawPointerDerefRule;
impl Rule for RawPointerDerefRule {
fn id(&self) -> &str {
"rust/raw-pointer-deref"
}
fn description(&self) -> &str {
"Detects raw pointer dereferences which may cause undefined behavior"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "unsafe_block", |unsafe_node: Node| {
let mut inner_cursor = unsafe_node.walk();
find_nodes_in_subtree(&mut inner_cursor, "unary_expression", |node: Node| {
if let Ok(text) = node.utf8_text(parsed.content.as_bytes()) {
if text.starts_with('*') {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Warning,
"Raw pointer dereference - ensure pointer validity",
Language::Rust,
Confidence::High,
));
}
}
});
});
findings
}
}
pub struct SqlInjectionHint;
impl SqlInjectionHint {
fn has_db_context(path: &std::path::Path, content: &str) -> bool {
let path_str = path.to_string_lossy().to_lowercase();
let db_path = path_str.contains("/db/")
|| path_str.contains("/database/")
|| path_str.contains("/repository/")
|| path_str.contains("/dao/")
|| path_str.contains("_repo")
|| path_str.ends_with("_db.rs");
let db_imports = ["sqlx", "diesel", "postgres", "rusqlite", "mysql", "sea_orm"]
.iter()
.any(|crate_name| content.contains(&format!("use {}::", crate_name)));
db_path || db_imports
}
fn has_db_api_call(text: &str) -> bool {
text.contains(".query(")
|| text.contains(".execute(")
|| text.contains("query!(")
|| text.contains("query_as!(")
|| text.contains(".prepare(")
}
}
impl Rule for SqlInjectionHint {
fn id(&self) -> &str {
"rust/sql-injection-hint"
}
fn description(&self) -> &str {
"Review hint: potential SQL injection if input is untrusted"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
if !Self::has_db_context(&parsed.path, &parsed.content) {
return findings;
}
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "macro_invocation", |node: Node| {
if let Ok(text) = node.utf8_text(parsed.content.as_bytes()) {
if text.starts_with("format!") && Self::has_db_api_call(text) {
let lower = text.to_lowercase();
let has_sql = lower.contains("select ")
|| lower.contains("insert ")
|| lower.contains("update ")
|| lower.contains("delete ");
if has_sql {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Warning,
"Potential SQL injection if input is untrusted - use parameterized queries",
Language::Rust,
Confidence::Medium,
));
}
}
}
});
findings
}
}
pub struct PathTraversalHint;
impl Rule for PathTraversalHint {
fn id(&self) -> &str {
"rust/path-traversal-hint"
}
fn description(&self) -> &str {
"Review hint: file path from untrusted input may allow directory traversal"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
const FILE_SINKS: &[&str] = &[
"File::open",
"File::create",
"fs::read",
"fs::read_to_string",
"fs::write",
"fs::remove_file",
"fs::remove_dir_all",
"std::fs::read",
"std::fs::write",
];
find_nodes_by_kind(&mut cursor, "call_expression", |node: Node| {
if let Some(func) = node.child(0) {
let func_text = func.utf8_text(parsed.content.as_bytes()).unwrap_or("");
let is_file_sink = FILE_SINKS.iter().any(|sink| func_text.ends_with(sink));
if is_file_sink {
if let Some(args) = node.child_by_field_name("arguments") {
let args_text = args.utf8_text(parsed.content.as_bytes()).unwrap_or("");
if args_text.contains("format!(") {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Info,
"File path from dynamic input - validate to prevent directory traversal if untrusted",
Language::Rust,
Confidence::Low,
));
}
}
}
}
});
findings
}
}
pub struct UnwrapHint;
impl Rule for UnwrapHint {
fn id(&self) -> &str {
"rust/unwrap-hint"
}
fn description(&self) -> &str {
"Review hint: unwrap/expect may panic"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "call_expression", |node: Node| {
if let Some(func) = node.child(0)
&& func.kind() == "field_expression"
{
let func_text = func.utf8_text(parsed.content.as_bytes()).unwrap_or("");
if func_text.ends_with(".unwrap") || func_text.ends_with(".expect") {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Info,
"Consider ? operator or proper error handling",
Language::Rust,
Confidence::Low,
));
}
}
});
findings
}
}
pub struct PanicHint;
impl Rule for PanicHint {
fn id(&self) -> &str {
"rust/panic-hint"
}
fn description(&self) -> &str {
"Review hint: panic macros crash the program"
}
fn applies_to(&self, lang: Language) -> bool {
lang == Language::Rust
}
fn check(&self, parsed: &ParsedFile) -> Vec<Finding> {
let mut findings = Vec::new();
let mut cursor = parsed.tree.walk();
find_nodes_by_kind(&mut cursor, "macro_invocation", |node: Node| {
if let Some(macro_node) = node.child_by_field_name("macro") {
let macro_text = macro_node
.utf8_text(parsed.content.as_bytes())
.unwrap_or("");
if macro_text == "panic" || macro_text == "todo" || macro_text == "unimplemented" {
findings.push(create_finding_with_confidence(
self.id(),
&node,
&parsed.path,
&parsed.content,
Severity::Info,
"Panic macro will crash - consider Result/Option for recoverable errors",
Language::Rust,
Confidence::Low,
));
}
}
});
findings
}
}
fn find_nodes_by_kind<F>(cursor: &mut tree_sitter::TreeCursor, kind: &str, mut callback: F)
where
F: FnMut(Node),
{
loop {
let node = cursor.node();
if node.kind() == kind {
callback(node);
}
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
fn find_nodes_in_subtree<F>(cursor: &mut tree_sitter::TreeCursor, kind: &str, mut callback: F)
where
F: FnMut(Node),
{
let start_depth = cursor.depth();
loop {
let node = cursor.node();
if node.kind() == kind {
callback(node);
}
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() || cursor.depth() < start_depth {
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rma_common::RmaConfig;
use rma_parser::ParserEngine;
use std::path::Path;
#[test]
fn test_unsafe_detection() {
let config = RmaConfig::default();
let parser = ParserEngine::new(config);
let content = r#"
fn main() {
unsafe {
let ptr = std::ptr::null::<i32>();
}
}
"#;
let parsed = parser.parse_file(Path::new("test.rs"), content).unwrap();
let rule = UnsafeBlockRule;
let findings = rule.check(&parsed);
assert!(!findings.is_empty());
assert_eq!(findings[0].confidence, Confidence::High);
}
#[test]
fn test_transmute_detection() {
let config = RmaConfig::default();
let parser = ParserEngine::new(config);
let content = r#"
fn danger() {
let x: u32 = unsafe { std::mem::transmute(1.0f32) };
}
"#;
let parsed = parser.parse_file(Path::new("test.rs"), content).unwrap();
let rule = TransmuteRule;
let findings = rule.check(&parsed);
assert!(!findings.is_empty());
assert_eq!(findings[0].confidence, Confidence::High);
}
#[test]
fn test_command_injection_with_dynamic_args() {
let config = RmaConfig::default();
let parser = ParserEngine::new(config);
let content = r#"
use std::process::Command;
fn run_shell(user_cmd: &str) {
Command::new("sh").arg("-c").arg(format!("echo {}", user_cmd)).output().unwrap();
}
"#;
let parsed = parser.parse_file(Path::new("test.rs"), content).unwrap();
let rule = CommandInjectionRule;
let findings = rule.check(&parsed);
assert!(!findings.is_empty(), "Should detect injection pattern");
assert_eq!(findings[0].confidence, Confidence::High);
assert_eq!(findings[0].severity, Severity::Critical);
}
#[test]
fn test_shell_spawn_without_injection() {
let config = RmaConfig::default();
let parser = ParserEngine::new(config);
let content = r#"
use std::process::Command;
fn get_env() {
Command::new("cmd").creation_flags(123).output().unwrap();
}
"#;
let parsed = parser.parse_file(Path::new("test.rs"), content).unwrap();
let injection_rule = CommandInjectionRule;
let findings = injection_rule.check(&parsed);
assert!(findings.is_empty(), "Plain shell spawn is not injection");
let spawn_rule = ShellSpawnRule;
let spawn_findings = spawn_rule.check(&parsed);
assert!(!spawn_findings.is_empty(), "Should detect shell spawn");
assert_eq!(spawn_findings[0].severity, Severity::Info);
}
}