Skip to content

Commit bffe1b1

Browse files
committed
fix(guards): preserve relative glob and exception compatibility
1 parent c27d757 commit bffe1b1

File tree

3 files changed

+172
-15
lines changed

3 files changed

+172
-15
lines changed

crates/libs/clawdstrike/src/guards/forbidden_path.rs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use async_trait::async_trait;
44
use glob::Pattern;
55
use serde::{Deserialize, Serialize};
66

7-
use super::path_normalization::{normalize_path_for_policy, normalize_path_for_policy_with_fs};
7+
use super::path_normalization::{
8+
normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
9+
normalize_path_for_policy_with_fs,
10+
};
811
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};
912

1013
/// Configuration for ForbiddenPathGuard
@@ -191,16 +194,28 @@ impl ForbiddenPathGuard {
191194
pub fn is_forbidden(&self, path: &str) -> bool {
192195
let lexical_path = normalize_path_for_policy(path);
193196
let resolved_path = normalize_path_for_policy_with_fs(path);
194-
let resolved_differs = resolved_path != lexical_path;
197+
let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
198+
let resolved_differs_from_lexical_target = lexical_abs_path
199+
.as_deref()
200+
.map(|abs| abs != resolved_path.as_str())
201+
.unwrap_or(resolved_path != lexical_path);
195202

196203
// Check exceptions first
197204
for exception in &self.exceptions {
198-
let exception_matches = if resolved_differs {
199-
// If resolution changed the path (e.g., via symlink/canonical host mount aliases),
200-
// require the exception to match the resolved target to avoid lexical bypasses.
201-
exception.matches(&resolved_path)
205+
let lexical_matches = exception.matches(&lexical_path)
206+
|| lexical_abs_path
207+
.as_deref()
208+
.map(|abs| exception.matches(abs))
209+
.unwrap_or(false);
210+
let resolved_matches = exception.matches(&resolved_path);
211+
let exception_matches = if resolved_differs_from_lexical_target {
212+
// If resolution changed the actual target (for example via symlink traversal),
213+
// require the exception to match the resolved target to prevent lexical bypasses.
214+
resolved_matches
202215
} else {
203-
exception.matches(&lexical_path)
216+
// If target identity is unchanged (for example relative -> absolute conversion),
217+
// allow either lexical or resolved exception forms.
218+
resolved_matches || lexical_matches
204219
};
205220

206221
if exception_matches {
@@ -309,6 +324,29 @@ mod tests {
309324
assert!(!guard.is_forbidden("/app/project/.env"));
310325
}
311326

327+
#[test]
328+
fn relative_exception_matches_when_target_is_unchanged() {
329+
let rel_dir = format!("target/forbidden-path-rel-{}", uuid::Uuid::new_v4());
330+
std::fs::create_dir_all(&rel_dir).expect("create rel dir");
331+
let rel_file = format!("{rel_dir}/.env");
332+
std::fs::write(&rel_file, "API_KEY=test\n").expect("write file");
333+
334+
let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
335+
enabled: true,
336+
patterns: Some(vec!["**/.env".to_string()]),
337+
exceptions: vec![rel_file.clone()],
338+
additional_patterns: vec![],
339+
remove_patterns: vec![],
340+
});
341+
342+
assert!(
343+
!guard.is_forbidden(&rel_file),
344+
"relative exception should match even when fs normalization produces absolute path"
345+
);
346+
347+
let _ = std::fs::remove_dir_all(&rel_dir);
348+
}
349+
312350
#[test]
313351
fn test_additional_patterns_field() {
314352
let yaml = r#"
@@ -398,4 +436,36 @@ remove_patterns:
398436

399437
let _ = std::fs::remove_dir_all(&root);
400438
}
439+
440+
#[cfg(unix)]
441+
#[test]
442+
fn lexical_exception_does_not_bypass_forbidden_resolved_target() {
443+
use std::os::unix::fs::symlink;
444+
445+
let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4()));
446+
let safe_dir = root.join("safe");
447+
let forbidden_dir = root.join("forbidden");
448+
std::fs::create_dir_all(&safe_dir).expect("create safe dir");
449+
std::fs::create_dir_all(&forbidden_dir).expect("create forbidden dir");
450+
451+
let target = forbidden_dir.join("secret.env");
452+
std::fs::write(&target, "secret").expect("write target");
453+
let link = safe_dir.join("project.env");
454+
symlink(&target, &link).expect("create symlink");
455+
456+
let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
457+
enabled: true,
458+
patterns: Some(vec!["**/forbidden/**".to_string()]),
459+
exceptions: vec!["**/safe/project.env".to_string()],
460+
additional_patterns: vec![],
461+
remove_patterns: vec![],
462+
});
463+
464+
assert!(
465+
guard.is_forbidden(link.to_str().expect("utf-8 path")),
466+
"lexical-only exception should not bypass when resolved target is forbidden"
467+
);
468+
469+
let _ = std::fs::remove_dir_all(&root);
470+
}
401471
}

crates/libs/clawdstrike/src/guards/path_allowlist.rs

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use async_trait::async_trait;
44
use glob::Pattern;
55
use serde::{Deserialize, Serialize};
66

7-
use super::path_normalization::normalize_path_for_policy_with_fs;
7+
use super::path_normalization::{
8+
normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
9+
normalize_path_for_policy_with_fs,
10+
};
811
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};
912

1013
/// Configuration for `PathAllowlistGuard`.
@@ -95,28 +98,49 @@ impl PathAllowlistGuard {
9598
patterns.iter().any(|p| p.matches(path))
9699
}
97100

101+
fn matches_allowlist(&self, patterns: &[Pattern], path: &str) -> bool {
102+
let lexical_path = normalize_path_for_policy(path);
103+
let resolved_path = normalize_path_for_policy_with_fs(path);
104+
let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
105+
106+
let resolved_differs_from_lexical_target = lexical_abs_path
107+
.as_deref()
108+
.map(|abs| abs != resolved_path.as_str())
109+
.unwrap_or(resolved_path != lexical_path);
110+
111+
if resolved_differs_from_lexical_target {
112+
// When resolution changes the target (for example, symlink traversal), require the
113+
// resolved path to match to prevent lexical-path allowlist bypasses.
114+
return Self::matches_any(patterns, &resolved_path);
115+
}
116+
117+
Self::matches_any(patterns, &lexical_path)
118+
|| Self::matches_any(patterns, &resolved_path)
119+
|| lexical_abs_path
120+
.as_deref()
121+
.map(|abs| Self::matches_any(patterns, abs))
122+
.unwrap_or(false)
123+
}
124+
98125
pub fn is_file_access_allowed(&self, path: &str) -> bool {
99126
if !self.enabled {
100127
return true;
101128
}
102-
let normalized = normalize_path_for_policy_with_fs(path);
103-
Self::matches_any(&self.file_access_allow, &normalized)
129+
self.matches_allowlist(&self.file_access_allow, path)
104130
}
105131

106132
pub fn is_file_write_allowed(&self, path: &str) -> bool {
107133
if !self.enabled {
108134
return true;
109135
}
110-
let normalized = normalize_path_for_policy_with_fs(path);
111-
Self::matches_any(&self.file_write_allow, &normalized)
136+
self.matches_allowlist(&self.file_write_allow, path)
112137
}
113138

114139
pub fn is_patch_allowed(&self, path: &str) -> bool {
115140
if !self.enabled {
116141
return true;
117142
}
118-
let normalized = normalize_path_for_policy_with_fs(path);
119-
Self::matches_any(&self.patch_allow, &normalized)
143+
self.matches_allowlist(&self.patch_allow, path)
120144
}
121145
}
122146

@@ -222,6 +246,36 @@ mod tests {
222246
assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs"));
223247
}
224248

249+
#[test]
250+
fn relative_allowlist_globs_match_when_target_is_unchanged() {
251+
let rel_dir = format!("target/path-allowlist-rel-{}", uuid::Uuid::new_v4());
252+
std::fs::create_dir_all(&rel_dir).expect("create rel dir");
253+
let rel_file = format!("{rel_dir}/main.rs");
254+
std::fs::write(&rel_file, "fn main() {}\n").expect("write file");
255+
256+
let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
257+
enabled: true,
258+
file_access_allow: vec![format!("{rel_dir}/**")],
259+
file_write_allow: vec![format!("{rel_dir}/**")],
260+
patch_allow: vec![format!("{rel_dir}/**")],
261+
});
262+
263+
assert!(
264+
guard.is_file_access_allowed(&rel_file),
265+
"relative file-access allowlist glob should match"
266+
);
267+
assert!(
268+
guard.is_file_write_allowed(&rel_file),
269+
"relative file-write allowlist glob should match"
270+
);
271+
assert!(
272+
guard.is_patch_allowed(&rel_file),
273+
"relative patch allowlist glob should match"
274+
);
275+
276+
let _ = std::fs::remove_dir_all(&rel_dir);
277+
}
278+
225279
#[cfg(unix)]
226280
#[test]
227281
fn symlink_escape_outside_allowlist_is_denied() {

crates/libs/clawdstrike/src/guards/path_normalization.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ pub fn normalize_path_for_policy(path: &str) -> String {
4848
}
4949
}
5050

51+
/// Normalize a path to an absolute lexical path without resolving symlinks.
52+
///
53+
/// - Absolute inputs are normalized as-is.
54+
/// - Relative inputs are joined against the current working directory.
55+
pub fn normalize_path_for_policy_lexical_absolute(path: &str) -> Option<String> {
56+
let raw = Path::new(path);
57+
if raw.is_absolute() {
58+
return Some(normalize_path_for_policy(path));
59+
}
60+
61+
let cwd = std::env::current_dir().ok()?;
62+
let joined = cwd.join(raw);
63+
Some(normalize_path_for_policy(&joined.to_string_lossy()))
64+
}
65+
5166
/// Normalize a path for policy matching, preferring filesystem-resolved targets when possible.
5267
///
5368
/// - For existing paths, this resolves symlinks via `canonicalize`.
@@ -74,7 +89,10 @@ fn resolve_path_for_policy(path: &str) -> Option<String> {
7489

7590
#[cfg(test)]
7691
mod tests {
77-
use super::{normalize_path_for_policy, normalize_path_for_policy_with_fs};
92+
use super::{
93+
normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
94+
normalize_path_for_policy_with_fs,
95+
};
7896

7997
#[test]
8098
fn normalizes_separators_and_dots() {
@@ -108,4 +126,19 @@ mod tests {
108126
);
109127
let _ = std::fs::remove_dir_all(&root);
110128
}
129+
130+
#[test]
131+
fn lexical_absolute_normalization_keeps_relative_shape_without_resolving_links() {
132+
let relative = format!(
133+
"target/path-normalization-{}/file.txt",
134+
uuid::Uuid::new_v4()
135+
);
136+
let expected = {
137+
let cwd = std::env::current_dir().expect("cwd");
138+
normalize_path_for_policy(&cwd.join(&relative).to_string_lossy())
139+
};
140+
let actual = normalize_path_for_policy_lexical_absolute(&relative)
141+
.expect("lexical absolute normalization should succeed");
142+
assert_eq!(actual, expected);
143+
}
111144
}

0 commit comments

Comments
 (0)