Skip to main content

rippy_cli/
ruby_safety.rs

1//! Heuristic safety analysis for inline Ruby source code.
2//!
3//! Scans for dangerous system calls, file operations, requires, and evals.
4//! Returns `true` if no dangerous patterns are found.
5
6const DANGEROUS_CALLS: &[&str] = &[
7    "system(",
8    "exec(",
9    "%x(",
10    "%x{",
11    "%x[",
12    "%x|",
13    "IO.popen(",
14    "Open3.",
15    "Kernel.system(",
16    "Kernel.exec(",
17    "Kernel.`",
18    "spawn(",
19];
20
21const DANGEROUS_FILE_OPS: &[&str] = &[
22    "File.delete(",
23    "File.unlink(",
24    "File.write(",
25    "File.open(",
26    "File.rename(",
27    "File.chmod(",
28    "File.chown(",
29    "FileUtils.rm",
30    "FileUtils.mv",
31    "FileUtils.cp",
32    "FileUtils.chmod",
33    "Dir.rmdir(",
34    "Dir.delete(",
35];
36
37const DANGEROUS_REQUIRES: &[&str] = &[
38    "open-uri",
39    "net/http",
40    "socket",
41    "webrick",
42    "open3",
43    "fileutils",
44];
45
46const DANGEROUS_EVALS: &[&str] = &[
47    "eval(",
48    "instance_eval(",
49    "class_eval(",
50    "module_eval(",
51    "binding.eval(",
52];
53
54/// Check whether inline Ruby source appears safe to execute.
55///
56/// This is a heuristic check — it may have false positives (blocking safe code)
57/// but should not have false negatives (allowing dangerous code).
58#[must_use]
59pub fn is_ruby_source_safe(source: &str) -> bool {
60    !has_dangerous_calls(source)
61        && !has_dangerous_file_ops(source)
62        && !has_dangerous_requires(source)
63        && !has_dangerous_evals(source)
64        && !has_backtick_execution(source)
65}
66
67fn has_dangerous_calls(source: &str) -> bool {
68    DANGEROUS_CALLS.iter().any(|c| source.contains(c))
69}
70
71fn has_dangerous_file_ops(source: &str) -> bool {
72    DANGEROUS_FILE_OPS.iter().any(|f| source.contains(f))
73}
74
75fn has_dangerous_requires(source: &str) -> bool {
76    for module in DANGEROUS_REQUIRES {
77        if source.contains(&format!("require \"{module}\""))
78            || source.contains(&format!("require '{module}'"))
79            || source.contains(&format!("require(\"{module}\")"))
80            || source.contains(&format!("require('{module}')"))
81        {
82            return true;
83        }
84    }
85    false
86}
87
88fn has_dangerous_evals(source: &str) -> bool {
89    DANGEROUS_EVALS.iter().any(|e| source.contains(e))
90}
91
92fn has_backtick_execution(source: &str) -> bool {
93    source.contains('`')
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn puts_is_safe() {
103        assert!(is_ruby_source_safe("puts 'hello'"));
104    }
105
106    #[test]
107    fn math_is_safe() {
108        assert!(is_ruby_source_safe("puts 2 ** 10"));
109    }
110
111    #[test]
112    fn array_is_safe() {
113        assert!(is_ruby_source_safe("[1,2,3].map { |x| x * 2 }"));
114    }
115
116    #[test]
117    fn empty_source_is_safe() {
118        assert!(is_ruby_source_safe(""));
119    }
120
121    #[test]
122    fn string_operations_safe() {
123        assert!(is_ruby_source_safe("'hello'.upcase.reverse"));
124    }
125
126    #[test]
127    fn system_is_dangerous() {
128        assert!(!is_ruby_source_safe("system('rm -rf /')"));
129    }
130
131    #[test]
132    fn exec_is_dangerous() {
133        assert!(!is_ruby_source_safe("exec('ls')"));
134    }
135
136    #[test]
137    fn backtick_is_dangerous() {
138        assert!(!is_ruby_source_safe("`rm -rf /`"));
139    }
140
141    #[test]
142    fn percent_x_is_dangerous() {
143        assert!(!is_ruby_source_safe("%x(rm -rf /)"));
144    }
145
146    #[test]
147    fn io_popen_is_dangerous() {
148        assert!(!is_ruby_source_safe("IO.popen('ls')"));
149    }
150
151    #[test]
152    fn file_delete_is_dangerous() {
153        assert!(!is_ruby_source_safe("File.delete('/tmp/x')"));
154    }
155
156    #[test]
157    fn file_write_is_dangerous() {
158        assert!(!is_ruby_source_safe("File.write('/tmp/x', 'data')"));
159    }
160
161    #[test]
162    fn fileutils_rm_is_dangerous() {
163        assert!(!is_ruby_source_safe("FileUtils.rm_rf('/')"));
164    }
165
166    #[test]
167    fn require_net_http_is_dangerous() {
168        assert!(!is_ruby_source_safe("require 'net/http'"));
169    }
170
171    #[test]
172    fn require_socket_is_dangerous() {
173        assert!(!is_ruby_source_safe("require 'socket'"));
174    }
175
176    #[test]
177    fn eval_is_dangerous() {
178        assert!(!is_ruby_source_safe("eval('code')"));
179    }
180
181    #[test]
182    fn kernel_system_is_dangerous() {
183        assert!(!is_ruby_source_safe("Kernel.system('ls')"));
184    }
185
186    #[test]
187    fn spawn_is_dangerous() {
188        assert!(!is_ruby_source_safe("spawn('ls')"));
189    }
190
191    #[test]
192    fn open3_is_dangerous() {
193        assert!(!is_ruby_source_safe("Open3.capture2('ls')"));
194    }
195}