guard_core_engine/patterns/
shell_validators.rs1use super::chars_util::char_before;
9use super::pyregex::PyRegex;
10
11pub const AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS: &[&str] = &["query_param", "url_path"];
12
13const STRONG_SQL_KEYWORD_GLUED_PREFIX_RE: &str =
14 r"\b(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\z";
15const STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE: &str =
16 r"\A(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|VALUES|ORDER\s+BY|GROUP\s+BY)\b";
17const BARE_SHELL_PARAMETER_NAME_RE: &str =
18 r"\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z";
19
20const BACKTICK_WINDOW_DELIMITERS: &[char] = &['`', '\'', '"', '\n', '\r'];
21
22const fn is_ascii_word(c: char) -> bool {
23 c.is_ascii_alphanumeric() || c == '_'
24}
25
26fn shell_text_is_printable_ascii(token: &str) -> bool {
27 token
28 .chars()
29 .all(|c| c == '\t' || ('\u{20}'..='\u{7e}').contains(&c))
30}
31
32#[allow(clippy::missing_const_for_fn)] fn backtick_token_has_chained_shell_operators(token: &str) -> bool {
34 let mut count = 0usize;
35 let bytes = token.as_bytes();
36 let mut i = 0usize;
37 while i < bytes.len() {
38 match bytes[i] {
39 b';' => {
40 count += 1;
41 i += 1;
42 }
43 b'|' | b'&' => {
44 count += 1;
45 if i + 1 < bytes.len() && bytes[i + 1] == bytes[i] {
46 i += 2;
47 } else {
48 i += 1;
49 }
50 }
51 _ => i += 1,
52 }
53 }
54 count >= 2
55}
56
57fn backtick_pair_glued(content: &str, start: usize, end: usize) -> bool {
58 let prefix_glued =
59 start > 0 && char_before(content, start).is_some_and(|(_, c)| is_ascii_word(c));
60 let suffix_glued = content[end..].chars().next().is_some_and(is_ascii_word);
61 prefix_glued || suffix_glued
62}
63
64const BACKTICK_CLAUSE_BOUNDARY_CHARS: &[char] = &['.', '!', '?', ';', '&', '|'];
65
66fn backtick_pair_tail_anchored(content: &str, end: usize) -> bool {
67 content[end..].trim().is_empty()
68}
69
70fn backtick_pair_clause_initial(content: &str, start: usize) -> bool {
71 if start == 0 {
72 return false;
73 }
74 if !char_before(content, start).is_some_and(|(_, c)| matches!(c, ' ' | '\t' | '\r' | '\n')) {
75 return false;
76 }
77 let prefix = content[..start].trim_end();
78 if prefix.is_empty() {
79 return false;
80 }
81 prefix
82 .chars()
83 .next_back()
84 .is_some_and(|c| BACKTICK_CLAUSE_BOUNDARY_CHARS.contains(&c))
85}
86
87fn backtick_pair_appended_clause(content: &str, start: usize, end: usize) -> bool {
88 backtick_pair_tail_anchored(content, end) && backtick_pair_clause_initial(content, start)
89}
90
91fn backtick_window_start(content: &str, position: usize) -> usize {
92 let mut index = position;
93 while index > 0
94 && char_before(content, index)
95 .is_some_and(|(_, c)| !BACKTICK_WINDOW_DELIMITERS.contains(&c))
96 {
97 index = char_before(content, index).map_or(0, |(i, _)| i);
98 }
99 index
100}
101
102fn backtick_window_end(content: &str, position: usize) -> usize {
103 content[position..]
104 .char_indices()
105 .find(|(_, c)| BACKTICK_WINDOW_DELIMITERS.contains(c))
106 .map_or(content.len(), |(i, _)| position + i)
107}
108
109fn backtick_pair_context_window(content: &str, start: usize, end: usize) -> &str {
110 let window_start = backtick_window_start(content, start);
111 let window_end = backtick_window_end(content, end);
112 &content[window_start..window_end]
113}
114
115fn token_is_implausible_sql_identifier(token: &str) -> bool {
116 token.chars().any(|c| {
118 matches!(
119 c,
120 ' ' | '\t'
121 | '\n'
122 | '\r'
123 | '\x0b'
124 | '\x0c'
125 | '/'
126 | '.'
127 | ';'
128 | '|'
129 | '&'
130 | '$'
131 | '('
132 | ')'
133 )
134 })
135}
136
137fn strong_sql_keyword_glued_to_pair(content: &str, start: usize, end: usize) -> bool {
138 let window_start = backtick_window_start(content, start);
139 let window_end = backtick_window_end(content, end);
140 let prefix = &content[window_start..start];
141 let suffix = &content[end..window_end];
142 let Ok(prefix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_PREFIX_RE, true) else {
143 return false;
144 };
145 if prefix_re.re().is_match(prefix) {
146 return true;
147 }
148 let Ok(suffix_re) = PyRegex::compile(STRONG_SQL_KEYWORD_GLUED_SUFFIX_RE, true) else {
149 return false;
150 };
151 suffix_re.re().is_match(suffix)
152}
153
154#[must_use]
156pub fn glued_backtick_pair_is_injection(
157 content: &str,
158 candidate: super::pyregex::Candidate,
159 request_context: &str,
160) -> bool {
161 let start = candidate.start;
162 let end = candidate.end;
163 let token = &content[start + 1..end - 1];
164 if !shell_text_is_printable_ascii(token) {
165 return false;
166 }
167 if backtick_token_has_chained_shell_operators(token) {
168 return true;
169 }
170 let appended_clause = backtick_pair_appended_clause(content, start, end);
171 if !backtick_pair_glued(content, start, end) && !appended_clause {
172 return false;
173 }
174 if token_is_implausible_sql_identifier(token) {
175 return true;
176 }
177 let window = backtick_pair_context_window(content, start, end);
178 if shell_metacharacter_window(window) {
179 return true;
180 }
181 if strong_sql_keyword_glued_to_pair(content, start, end) {
182 return false;
183 }
184 let normalized = request_context.split(':').next().unwrap_or(request_context);
185 AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized) || appended_clause
186}
187
188#[allow(clippy::missing_const_for_fn)] fn shell_metacharacter_window(window: &str) -> bool {
191 let bytes = window.as_bytes();
192 let mut i = 0usize;
193 while i < bytes.len() {
194 let op_len = if bytes[i] == b';' {
196 1
197 } else if (bytes[i] == b'|' || bytes[i] == b'&')
198 && i + 1 < bytes.len()
199 && bytes[i + 1] == bytes[i]
200 {
201 2
202 } else if bytes[i] == b'|' || bytes[i] == b'&' {
203 1
204 } else {
205 i += 1;
206 continue;
207 };
208 let mut j = i + op_len;
209 while j < bytes.len() && (bytes[j] as char).is_whitespace() {
210 j += 1;
211 }
212 if j >= bytes.len() {
213 i += op_len;
214 continue;
215 }
216 if bytes[j] == b'`' {
218 return true;
219 }
220 if bytes[j] == b'$' && j + 1 < bytes.len() && (bytes[j + 1] == b'(' || bytes[j + 1] == b'{')
221 {
222 return true;
223 }
224 if bytes[j] == b'_' || bytes[j].is_ascii_alphabetic() {
226 let mut k = j + 1;
227 while k < bytes.len()
228 && (bytes[k] == b'_' || bytes[k] == b'-' || bytes[k].is_ascii_alphanumeric())
229 {
230 k += 1;
231 }
232 return true;
233 }
234 if matches!(bytes[j], b'~' | b'.' | b'/') {
236 let mut k = j + 1;
237 while k < bytes.len()
238 && (bytes[k] == b'_'
239 || bytes[k] == b'.'
240 || bytes[k] == b'/'
241 || bytes[k] == b'-'
242 || bytes[k].is_ascii_alphanumeric())
243 {
244 k += 1;
245 }
246 return true;
247 }
248 if bytes[j] == b'-' {
250 return true;
251 }
252 i = j;
253 }
254 false
255}
256
257fn dollar_substitution_token_is_implausible(token: &str, delimiter: char) -> bool {
258 let stripped = token.trim().to_lowercase();
259 if stripped == "ifs" {
260 return true;
261 }
262 if delimiter == '{' {
263 let Ok(re) = PyRegex::compile(BARE_SHELL_PARAMETER_NAME_RE, false) else {
264 return true;
265 };
266 return re
267 .re()
268 .find(token.trim())
269 .is_none_or(|m| m.start() != 0 || m.end() != token.trim().len());
270 }
271 token
273 .chars()
274 .any(|c| matches!(c, '/' | '.' | ';' | '|' | '&' | '$' | '(' | ')'))
275}
276
277fn dollar_substitution_pair_backtick_quoted(content: &str, start: usize, end: usize) -> bool {
278 let prefix_quoted = start > 0 && char_before(content, start).is_some_and(|(_, c)| c == '`');
279 let suffix_quoted = content[end..].chars().next().is_some_and(|c| c == '`');
280 prefix_quoted || suffix_quoted
281}
282
283#[must_use]
285pub fn dollar_substitution_pair_is_injection(
286 content: &str,
287 candidate: super::pyregex::Candidate,
288 request_context: &str,
289) -> bool {
290 let start = candidate.start;
291 let end = candidate.end;
292 if dollar_substitution_pair_backtick_quoted(content, start, end) {
293 return false;
294 }
295 let Some(delimiter) = content[start + 1..].chars().next() else {
296 return false;
297 };
298 let token = &content[start + 2..end - 1];
299 if dollar_substitution_token_is_implausible(token, delimiter) {
300 return true;
301 }
302 if strong_sql_keyword_glued_to_pair(content, start, end) {
303 return false;
304 }
305 let normalized = request_context.split(':').next().unwrap_or(request_context);
306 AMBIGUOUS_BACKTICK_INJECTION_CONTEXTS.contains(&normalized)
307}
308
309#[must_use]
312pub fn quote_splice_token_is_dangerous_command(token: &str) -> bool {
313 let mut run = 0usize;
314 for fragment in token.split(['\'', '"']) {
315 let chars = fragment.chars().count();
316 run = if chars == 1 { run + 1 } else { 0 };
317 if run >= 3 {
318 return true;
319 }
320 }
321 false
322}
323
324const GLOB_WILDCARD_COMMAND_SUFFIX_CHARS: &[char] = &[' ', '\t', '\r', '\n', ';', '|', '&'];
325const GLOB_WILDCARD_VALUE_START_CONTEXTS: &[&str] = &["request_body"];
326
327fn glob_wildcard_token_is_word_shaped(token: &str) -> bool {
328 let chars: Vec<char> = token.chars().collect();
329 for (index, c) in chars.iter().enumerate() {
330 if *c != '?' && *c != '*' {
331 continue;
332 }
333 let mut left = 0usize;
334 let mut position = index;
335 while position > 0 && chars[position - 1].is_ascii_alphabetic() {
336 left += 1;
337 position -= 1;
338 }
339 let mut right = 0usize;
340 position = index + 1;
341 while position < chars.len() && chars[position].is_ascii_alphabetic() {
342 right += 1;
343 position += 1;
344 }
345 if left + right >= 2 {
346 return true;
347 }
348 }
349 false
350}
351
352#[must_use]
354pub fn glob_wildcard_token_is_dangerous_command(
355 content: &str,
356 candidate: super::pyregex::Candidate,
357 request_context: &str,
358) -> bool {
359 if !glob_wildcard_token_is_word_shaped(candidate.text(content)) {
360 return false;
361 }
362 let suffix = content[candidate.end..].chars().next();
363 if let Some(c) = suffix
364 && !GLOB_WILDCARD_COMMAND_SUFFIX_CHARS.contains(&c)
365 {
366 return false;
367 }
368 let prefix = &content[..candidate.start];
369 if glob_command_boundary_prefix(prefix) {
370 return true;
371 }
372 if GLOB_WILDCARD_VALUE_START_CONTEXTS.contains(&request_context) {
373 return prefix.trim().is_empty();
374 }
375 false
376}
377
378fn glob_command_boundary_prefix(prefix: &str) -> bool {
380 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
381 let Some(last) = trimmed.chars().next_back() else {
382 return false;
383 };
384 match last {
385 ';' | '`' | '|' | '&' => true,
387 '$' => trimmed.as_bytes().get(trimmed.len() - 2) == Some(&b'('),
388 _ => false,
389 }
390}
391
392#[must_use]
395pub fn brace_expansion_is_dangerous_command(candidate_text: &str) -> bool {
396 let Some(start) = candidate_text.find('{') else {
397 return false;
398 };
399 let Some(end) = candidate_text.rfind('}') else {
400 return false;
401 };
402 if end <= start {
403 return false;
404 }
405 candidate_text[start + 1..end].split(',').any(|item| {
406 !item.is_empty()
407 && item
408 .chars()
409 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '~' | '-'))
410 && item.chars().any(|c| c.is_ascii_alphabetic())
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::super::pyregex::Candidate;
417 use super::*;
418
419 #[test]
420 fn quote_splice_three_single_char_fragments() {
421 assert!(quote_splice_token_is_dangerous_command("a'b'c"));
422 assert!(!quote_splice_token_is_dangerous_command("ab'cd"));
423 assert!(!quote_splice_token_is_dangerous_command("it's"));
424 }
425
426 #[test]
427 fn brace_expansion_word_items() {
428 assert!(brace_expansion_is_dangerous_command(";{a,b,c}"));
429 assert!(brace_expansion_is_dangerous_command(";{echo,id}"));
430 assert!(!brace_expansion_is_dangerous_command(";{1,2,3}"));
431 assert!(brace_expansion_is_dangerous_command(";{a-b,c}"));
433 }
434
435 #[test]
436 fn glob_word_shaped_after_boundary() {
437 assert!(!glob_wildcard_token_is_dangerous_command(
439 "ls *.txt",
440 Candidate::new(3, 8),
441 "unknown"
442 ));
443 assert!(glob_wildcard_token_is_dangerous_command(
445 "; file* ",
446 Candidate::new(2, 7),
447 "unknown"
448 ));
449 assert!(!glob_wildcard_token_is_dangerous_command(
451 "file* ",
452 Candidate::new(0, 5),
453 "unknown"
454 ));
455 }
456
457 #[test]
458 fn glob_value_start_only_in_request_body() {
459 assert!(glob_wildcard_token_is_dangerous_command(
460 "file* ",
461 Candidate::new(0, 5),
462 "request_body"
463 ));
464 assert!(!glob_wildcard_token_is_dangerous_command(
465 "file* ",
466 Candidate::new(0, 5),
467 "query_param"
468 ));
469 }
470
471 #[test]
472 fn backtick_glued_injection() {
473 let content = "x`id`y";
475 assert!(glued_backtick_pair_is_injection(
476 content,
477 Candidate::new(1, 5),
478 "query_param"
479 ));
480 assert!(!glued_backtick_pair_is_injection(
481 content,
482 Candidate::new(1, 5),
483 "request_body"
484 ));
485 }
486
487 #[test]
488 fn backtick_sql_glue_negated() {
489 let content = "SELECT`a`FROM";
490 assert!(!glued_backtick_pair_is_injection(
491 content,
492 Candidate::new(6, 9),
493 "request_body"
494 ));
495 }
496
497 #[test]
498 fn dollar_substitution_implausible() {
499 let content = ";$(rm -rf /)";
501 assert!(dollar_substitution_pair_is_injection(
502 content,
503 Candidate::new(1, 12),
504 "request_body"
505 ));
506 let content = ";$(rm -rf)";
508 assert!(dollar_substitution_pair_is_injection(
509 content,
510 Candidate::new(1, 9),
511 "query_param"
512 ));
513 let content = ";${IFS}";
514 assert!(dollar_substitution_pair_is_injection(
515 content,
516 Candidate::new(1, 7),
517 "request_body"
518 ));
519 let content = ";${PATH}";
520 assert!(!dollar_substitution_pair_is_injection(
521 content,
522 Candidate::new(1, 8),
523 "request_body"
524 ));
525 }
526}