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}