Skip to main content

shell_sanitize_rules/
env_expansion.rs

1use shell_sanitize::{Rule, RuleResult, RuleViolation};
2
3/// Rejects input containing environment variable expansion patterns.
4///
5/// Detects:
6/// - `$NAME` (simple variable reference)
7/// - `${NAME}` (braced variable reference)
8/// - `%NAME%` (Windows-style variable reference)
9///
10/// # Intentionally allowed patterns
11///
12/// The following patterns are **not** flagged:
13///
14/// - **Positional parameters** (`$1`, `$2`, …) — These start with a digit
15///   and are not environment variable names. They are common in pricing text
16///   (e.g. `"$5 off"`) and typically cannot leak secrets.
17/// - **Lone `$` at end of input** — Not a valid expansion.
18///
19/// If your use case requires blocking positional parameters as well,
20/// combine this rule with [`ShellMetaRule`](crate::ShellMetaRule), which
21/// rejects the `$` character itself.
22///
23/// # Rationale
24///
25/// Environment variable expansion can leak secrets or alter behavior:
26/// ```text
27/// $HOME/.ssh/id_rsa   → leaks home directory
28/// ${SECRET_KEY}        → leaks credentials
29/// %USERPROFILE%        → Windows equivalent
30/// ```
31pub struct EnvExpansionRule {
32    /// Whether to check Windows-style `%VAR%` patterns.
33    check_windows: bool,
34}
35
36impl Default for EnvExpansionRule {
37    fn default() -> Self {
38        Self {
39            check_windows: true,
40        }
41    }
42}
43
44impl EnvExpansionRule {
45    /// Create a POSIX-only rule (skip `%VAR%` detection).
46    pub fn posix_only() -> Self {
47        Self {
48            check_windows: false,
49        }
50    }
51}
52
53impl Rule for EnvExpansionRule {
54    fn name(&self) -> &'static str {
55        "env_expansion"
56    }
57
58    fn check(&self, input: &str) -> RuleResult {
59        let mut violations = Vec::new();
60
61        check_dollar_patterns(self.name(), input, &mut violations);
62
63        if self.check_windows {
64            check_percent_patterns(self.name(), input, &mut violations);
65        }
66
67        if violations.is_empty() {
68            Ok(())
69        } else {
70            Err(violations)
71        }
72    }
73}
74
75/// Detect `$NAME` and `${NAME}` patterns.
76///
77/// ASCII-only scan: all sentinel characters (`$`, `{`, `(`, `_`) are single-byte
78/// ASCII, so byte indices used for `&input[..]` slicing are always valid UTF-8
79/// char boundaries.
80fn check_dollar_patterns(
81    rule_name: &'static str,
82    input: &str,
83    violations: &mut Vec<RuleViolation>,
84) {
85    let bytes = input.as_bytes();
86    let len = bytes.len();
87    let mut i = 0;
88
89    while i < len {
90        if bytes[i] == b'$' && i + 1 < len {
91            let next = bytes[i + 1];
92
93            if next == b'{' {
94                // ${...} pattern
95                if let Some(end) = input[i + 2..].find('}') {
96                    let var_name = &input[i + 2..i + 2 + end];
97                    if !var_name.is_empty() {
98                        violations.push(
99                            RuleViolation::new(
100                                rule_name,
101                                format!("environment variable expansion ${{{}}} found", var_name),
102                            )
103                            .at(i)
104                            .with_fragment(format!("${{{}}}", var_name)),
105                        );
106                    }
107                    i = i + 2 + end + 1;
108                    continue;
109                }
110            } else if next == b'(' {
111                // $(...) command substitution — handled by ShellMetaRule via `$`
112                // but we flag it here too for completeness.
113                violations.push(
114                    RuleViolation::new(rule_name, "command substitution $(...) found")
115                        .at(i)
116                        .with_fragment("$("),
117                );
118                i += 2;
119                continue;
120            } else if next.is_ascii_alphabetic() || next == b'_' {
121                // $NAME pattern
122                let start = i;
123                let var_start = i + 1;
124                let mut var_end = var_start + 1;
125                while var_end < len
126                    && (bytes[var_end].is_ascii_alphanumeric() || bytes[var_end] == b'_')
127                {
128                    var_end += 1;
129                }
130                let var_name = &input[var_start..var_end];
131                violations.push(
132                    RuleViolation::new(
133                        rule_name,
134                        format!("environment variable expansion ${} found", var_name),
135                    )
136                    .at(start)
137                    .with_fragment(format!("${}", var_name)),
138                );
139                i = var_end;
140                continue;
141            }
142        }
143        i += 1;
144    }
145}
146
147/// Detect `%NAME%` patterns (Windows).
148///
149/// ASCII-only scan: `%` is a single-byte ASCII character, so byte indices
150/// are always valid UTF-8 char boundaries.
151fn check_percent_patterns(
152    rule_name: &'static str,
153    input: &str,
154    violations: &mut Vec<RuleViolation>,
155) {
156    let bytes = input.as_bytes();
157    let len = bytes.len();
158    let mut i = 0;
159
160    while i < len {
161        if bytes[i] == b'%' && i + 1 < len {
162            // Look for closing %
163            if let Some(end) = input[i + 1..].find('%') {
164                let var_name = &input[i + 1..i + 1 + end];
165                // Require at least one alphanumeric char (skip `%%` empty)
166                if !var_name.is_empty()
167                    && var_name
168                        .bytes()
169                        .all(|b| b.is_ascii_alphanumeric() || b == b'_')
170                {
171                    violations.push(
172                        RuleViolation::new(
173                            rule_name,
174                            format!("Windows environment variable %{}% found", var_name),
175                        )
176                        .at(i)
177                        .with_fragment(format!("%{}%", var_name)),
178                    );
179                    i = i + 1 + end + 1;
180                    continue;
181                }
182            }
183        }
184        i += 1;
185    }
186}