1const 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#[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}