Skip to main content

guard_core_engine/patterns/
shell_validators.rs

1//! Candidate-rejection validators ported from
2//! `guard_core/handlers/_suspatterns_shell_validators.py` (spec 4.0.2).
3//!
4//! A validator receives a candidate match plus the detection context and
5//! decides whether the match is a true injection; returning false rejects the
6//! candidate and lets the scan continue with later matches.
7
8use 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)] // clippy version drift: newer lints flag these
33fn 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    // [\s/.;|&$()]
117    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/// `_glued_backtick_pair_is_injection`.
155#[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/// `(?:;|\|\||\||&&)\s*(?:\x60|[A-Za-z_][\w-]*|[~./][\w./-]*|-[\w-]*)|\$\(|\$\{`
189#[allow(clippy::missing_const_for_fn)] // clippy version drift: newer lints flag these
190fn shell_metacharacter_window(window: &str) -> bool {
191    let bytes = window.as_bytes();
192    let mut i = 0usize;
193    while i < bytes.len() {
194        // operator
195        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        // `\x60` (backtick) or `$(`/`${`
217        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        // [A-Za-z_][\w-]*
225        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        // [~./][\w./-]*
235        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        // -[\w-]*
249        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    // [/.;|&$()]
272    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/// `_dollar_substitution_pair_is_injection`.
284#[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/// `_quote_splice_token_is_dangerous_command`: >= 3 consecutive 1-char
310/// fragments when splitting the token on quote runs.
311#[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/// `_glob_wildcard_token_is_dangerous_command`.
353#[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
378/// `(?:;|\|\||\||&&|\$\(|\x60)\s*$`
379fn 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        // `||`, `&&`, `|`, `&` all end with the same char, as do `;` and backtick
386        ';' | '`' | '|' | '&' => true,
387        '$' => trimmed.as_bytes().get(trimmed.len() - 2) == Some(&b'('),
388        _ => false,
389    }
390}
391
392/// `_brace_expansion_is_dangerous_command`: any comma-separated item is a
393/// word-shaped token containing a letter.
394#[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        // word-shaped items with dashes are still dangerous
432        assert!(brace_expansion_is_dangerous_command(";{a-b,c}"));
433    }
434
435    #[test]
436    fn glob_word_shaped_after_boundary() {
437        // `*.txt` has no adjacent letters: not word-shaped, never dangerous
438        assert!(!glob_wildcard_token_is_dangerous_command(
439            "ls *.txt",
440            Candidate::new(3, 8),
441            "unknown"
442        ));
443        // word-shaped token after a command boundary
444        assert!(glob_wildcard_token_is_dangerous_command(
445            "; file* ",
446            Candidate::new(2, 7),
447            "unknown"
448        ));
449        // word-shaped but no command boundary before it
450        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        // glued backtick pairs only fire in the ambiguous contexts
474        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        // implausible $() token (path separator inside)
500        let content = ";$(rm -rf /)";
501        assert!(dollar_substitution_pair_is_injection(
502            content,
503            Candidate::new(1, 12),
504            "request_body"
505        ));
506        // plausible $() token: only ambiguous contexts accept it
507        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}