Skip to content

Commit 76ac25c

Browse files
authored
Audit fix/2026 02 10 remediation (#53)
* docs(audits): add 2026-02 rust security correctness audit * fix(audit): remediate CS-AUDIT-001..006 * fix(hush-cli): harden CONNECT proxy policy/resource bounds * fix(clawdstrike): fail closed on IRM path and URL spoof bypasses * fix(remote-extends): validate git refs and block option injection * fix(policy): bound extends recursion and async background inflight * fix(audit): finalize remediation evidence and ipv6 parity * fix(review): parse userless SCP remotes and prune session locks atomically * fix(remote-extends): treat scheme remotes as URLs before scp parsing * fix(guards): preserve relative glob and exception compatibility * fix(remote-extends): allow cached git policy resolution offline * fix(security): tighten fs path extraction and honor forwarder test timeout * ci: remove redundant changed-paths workflow * fix(irm): treat dotted mime-like segments as filesystem paths
1 parent 2378321 commit 76ac25c

File tree

6 files changed

+295
-252
lines changed

6 files changed

+295
-252
lines changed

.github/workflows/ci-changed-paths.yml

Lines changed: 0 additions & 222 deletions
This file was deleted.

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
}

0 commit comments

Comments
 (0)