1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
//! SEC010: Path Traversal Vulnerabilities
//!
//! **Rule**: Detect path traversal risks in file operations
//!
//! **Why this matters**:
//! Path traversal vulnerabilities allow attackers to access files outside intended directories
//! by using sequences like `../` or absolute paths. This can lead to unauthorized file access,
//! data theft, or system compromise.
//!
//! **Auto-fix**: Manual review required (context-dependent validation needed)
//!
//! ## Examples
//!
//! ❌ **CRITICAL VULNERABILITY**:
//! ```bash
//! # Dangerous - user could provide "../../../../etc/passwd"
//! cp "$USER_FILE" /destination/
//! cat "$INPUT_PATH"
//! tar -xf "$ARCHIVE" # Could extract outside intended directory
//!
//! # Dangerous - no validation of path
//! mkdir -p "$USER_DIR"
//! cd "$USER_PATH"
//! ```
//!
//! ✅ **SAFE ALTERNATIVES**:
//! ```bash
//! # Validate path doesn't contain ../
//! if [[ "$USER_FILE" == *".."* ]] || [[ "$USER_FILE" == /* ]]; then
//! echo "Invalid path" >&2
//! exit 1
//! fi
//! cp "$USER_FILE" /destination/
//!
//! # Use realpath to resolve and validate
//! REAL_PATH=$(realpath -m "$USER_FILE")
//! if [[ "$REAL_PATH" != /safe/base/path/* ]]; then
//! echo "Path outside allowed directory" >&2
//! exit 1
//! fi
//! ```
use crate::linter::taint::{self, TaintKind, TaintMap};
use crate::linter::{Diagnostic, LintResult, Severity, Span};
/// File operation commands that are path traversal vectors
const FILE_OPS: &[&str] = &["cp", "mv", "cat", "tar", "unzip", "rm", "mkdir", "cd", "ln"];
/// Patterns that indicate potential path traversal
const TRAVERSAL_PATTERNS: &[&str] = &[
"..", // Parent directory reference
"../", // Parent directory path
"/..", // Absolute parent reference
];
/// Check file operations for path traversal risks.
///
/// GH-227: a file operation is a traversal risk only when the path expression
/// can be influenced from outside the script. The old implementation asked
/// `contains_unvalidated_variable` alone, which matched `mkdir -p "$OUT_DIR"`
/// on the substring `DIR` even for a literal path.
fn check_file_ops(line: &str, line_num: usize, taint: &TaintMap, result: &mut LintResult) {
let kind = taint.line_taint(line_num, line);
if kind == TaintKind::Clean {
return;
}
for file_op in FILE_OPS {
let Some(cmd_col) = find_command(line, file_op) else {
continue;
};
if !contains_unvalidated_variable(line, file_op) {
continue;
}
let span = Span::new(line_num + 1, cmd_col + 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC010",
severity_for(kind),
format!("Path traversal risk in {} - validate paths don't contain '..' or start with '/'", file_op),
span,
);
result.add(diag);
break;
}
}
/// GH-227: grade the severity by provenance.
///
/// Proven external input reaching an unguarded path is a vulnerability and
/// keeps exit code 2. A variable this file never assigns is a *guess* about
/// the environment, and a guess must not break a build.
fn severity_for(kind: TaintKind) -> Severity {
match kind {
TaintKind::External => Severity::Error,
_ => Severity::Warning,
}
}
/// Check for explicit traversal patterns (e.g. ".." in literal paths)
fn check_traversal_patterns(line: &str, line_num: usize, result: &mut LintResult) {
for pattern in TRAVERSAL_PATTERNS {
if line.contains(pattern) && contains_file_operation(line) {
if line.contains("BASH_SOURCE") || line.contains("dirname") {
continue;
}
if !is_validation_context(line) {
if let Some(pos) = line.find(pattern) {
let span = Span::new(line_num + 1, pos + 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC010",
Severity::Warning,
"Path contains traversal sequence '..' - ensure this is intentional and validated",
span,
);
result.add(diag);
break;
}
}
}
}
}
/// Check for path traversal vulnerabilities
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let taint = taint::analyze(source);
// GH-227: the body of a quoted heredoc is data, not shell. The taint pass
// deliberately ignores it, so the rule must ignore it too — otherwise a
// variable "assigned" inside the body reads as unknown-provenance and the
// rule fires on a line the script never executes.
let heredoc_body = crate::linter::heredoc::quoted_heredoc_lines(source);
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#')
|| is_heredoc_pattern(line)
|| heredoc_body.contains(&(line_num + 1))
{
continue;
}
check_file_ops(line, line_num, &taint, &mut result);
check_traversal_patterns(line, line_num, &mut result);
}
result
}
/// Find a command in a line (word boundary detection)
fn find_command(line: &str, cmd: &str) -> Option<usize> {
if let Some(pos) = line.find(cmd) {
let before_ok = if pos == 0 {
true
} else {
let char_before = line.chars().nth(pos - 1);
matches!(char_before, Some(' ' | '\t' | ';' | '&' | '|' | '(' | '\n'))
};
let after_idx = pos + cmd.len();
let after_ok = if after_idx >= line.len() {
true
} else {
let char_after = line.chars().nth(after_idx);
matches!(char_after, Some(' ' | '\t' | ';' | '&' | '|' | ')'))
};
if before_ok && after_ok {
return Some(pos);
}
}
None
}
/// Issue #73: Known-safe patterns that should not trigger SEC010
// These are checked as exact variable names (with $ or ${} wrapper)
const SAFE_VAR_PATTERNS: &[&str] = &[
"$PWD", // Current directory is intentional
"${PWD}", // Current directory is intentional
"$HOME", // User's home directory is safe
"${HOME}", // User's home directory is safe
"$TMPDIR", // Temp directory is safe
"${TMPDIR}", // Temp directory is safe
"BASH_SOURCE", // Script's own directory is safe
"dirname", // dirname of script is safe
"XDG_", // XDG directories are safe
];
/// Substrings that suggest untrusted or user-provided input.
///
/// GH-227 note: this is a NAME heuristic over the whole line, not a dataflow
/// fact. It is now only one of two necessary conditions — `check_file_ops`
/// also requires `crate::linter::taint` to say the line can be influenced from
/// outside the script.
const USER_INPUT_PATTERNS: &[&str] = &[
"USER", // USER_FILE, USER_PATH, etc.
"INPUT", // INPUT_PATH, INPUT_FILE, etc.
"UPLOAD", // Uploaded files
"ARCHIVE", // Archive files (could be user-provided)
"UNTRUSTED", // Explicitly untrusted
"EXTERNAL", // External input
"REMOTE", // Remote data
"ARG", // Command line arguments
"NAME", // Could be user-provided name
"FILE", // Generic file variables
"PATH", // Generic path variables (but not PATH env var)
"DIR", // Generic directory variables
];
/// Issue #73: the line uses only patterns known to be safe.
fn is_known_safe_line(line: &str) -> bool {
if SAFE_VAR_PATTERNS.iter().any(|p| line.contains(p)) {
return true;
}
// Script directory parent (..) with BASH_SOURCE is intentional:
// `cd "$(dirname "${BASH_SOURCE[0]}")/.."`
line.contains("dirname") && line.contains("..")
}
/// Every `PATH` mention on the line is the `PATH` environment variable.
fn only_path_env_var(line: &str) -> bool {
if !line.contains("$PATH") && !line.contains("${PATH}") {
return false;
}
let path_count = line.matches("PATH").count();
let dollar_path_count = line.matches("$PATH").count() + line.matches("${PATH}").count();
path_count == dollar_path_count
}
/// Check if line contains unvalidated variable in file operation
fn contains_unvalidated_variable(line: &str, _cmd: &str) -> bool {
// Look for variable usage: $VAR, ${VAR}, "$VAR"
if !line.contains('$') {
return false;
}
if is_known_safe_line(line) || only_path_env_var(line) {
return false;
}
// If no suspicious pattern is found, assume it's safe. This reduces false
// positives for common scripts.
let line_upper = line.to_uppercase();
USER_INPUT_PATTERNS
.iter()
.any(|pattern| line_upper.contains(pattern))
}
/// Check if line contains any file operation
fn contains_file_operation(line: &str) -> bool {
FILE_OPS.iter().any(|op| find_command(line, op).is_some())
}
/// Check if this is a validation context (checking for ..)
fn is_validation_context(line: &str) -> bool {
// Common validation patterns
let validation_keywords = ["if", "case", "grep", "=~", "==", "!="];
validation_keywords.iter().any(|kw| line.contains(kw))
}
/// Issue #106: Check if this is a heredoc pattern
/// Heredocs like `cat <<EOF` or `cat <<'EOF'` are not file reads
fn is_heredoc_pattern(line: &str) -> bool {
// Check for heredoc operators: << or <<<
if line.contains("<<") {
// Common heredoc patterns with file commands
// cat <<EOF, cat <<'EOF', cat <<"EOF", cat <<-EOF
// Also handles here-string: cat <<<
let heredoc_patterns = [
"cat <<", "cat<<<", "cat <<-", "echo <<", "read <<", "tee <<",
];
for pattern in &heredoc_patterns {
if line.contains(pattern) {
return true;
}
}
// Also check for $(...) containing heredoc
// e.g., content=$(cat <<EOF ... EOF)
if line.contains("$(cat <<") || line.contains("$(cat<<") {
return true;
}
}
false
}
#[cfg(test)]
#[path = "sec010_tests_sec010_detec.rs"]
mod tests_ext;