Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions crates/libs/clawdstrike/src/async_guards/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use dashmap::DashMap;
Expand All @@ -14,6 +15,8 @@ use crate::async_guards::types::{
use crate::guards::{GuardAction, GuardContext, GuardResult, Severity};
use crate::policy::{AsyncExecutionMode, TimeoutBehavior};

const DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT: usize = 64;

#[derive(Clone, Debug)]
pub enum OwnedGuardAction {
FileAccess {
Expand Down Expand Up @@ -94,6 +97,11 @@ pub struct AsyncGuardRuntime {
caches: DashMap<String, Arc<TtlCache>>,
limiters: DashMap<String, Arc<TokenBucket>>,
breakers: DashMap<String, Arc<CircuitBreaker>>,
background_slots: Arc<tokio::sync::Semaphore>,
background_in_flight_limit: usize,
background_running: AtomicUsize,
background_peak_running: AtomicUsize,
background_dropped: AtomicUsize,
}

impl Default for AsyncGuardRuntime {
Expand All @@ -104,18 +112,44 @@ impl Default for AsyncGuardRuntime {

impl AsyncGuardRuntime {
pub fn new() -> Self {
Self::with_background_in_flight_limit(DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT)
}

pub fn with_background_in_flight_limit(limit: usize) -> Self {
let limit = limit.max(1);
Self {
http: HttpClient::new(),
caches: DashMap::new(),
limiters: DashMap::new(),
breakers: DashMap::new(),
background_slots: Arc::new(tokio::sync::Semaphore::new(limit)),
background_in_flight_limit: limit,
background_running: AtomicUsize::new(0),
background_peak_running: AtomicUsize::new(0),
background_dropped: AtomicUsize::new(0),
}
}

pub fn http(&self) -> &HttpClient {
&self.http
}

pub fn background_inflight_limit(&self) -> usize {
self.background_in_flight_limit
}

pub fn background_inflight_count(&self) -> usize {
self.background_running.load(Ordering::Relaxed)
}

pub fn background_peak_inflight(&self) -> usize {
self.background_peak_running.load(Ordering::Relaxed)
}

pub fn background_dropped_count(&self) -> usize {
self.background_dropped.load(Ordering::Relaxed)
}

pub async fn evaluate_async_guards(
self: &Arc<Self>,
guards: &[Arc<dyn AsyncGuard>],
Expand Down Expand Up @@ -210,13 +244,28 @@ impl AsyncGuardRuntime {

background.sort_by_key(|(idx, _)| *idx);
for (_idx, g) in background {
self.spawn_background(g.clone(), owned_action.clone(), ctx.clone());
out.push(
GuardResult::allow(g.name()).with_details(serde_json::json!({
"background": true,
"note": "scheduled"
})),
);
if self.spawn_background(g.clone(), owned_action.clone(), ctx.clone()) {
out.push(
GuardResult::allow(g.name()).with_details(serde_json::json!({
"background": true,
"note": "scheduled",
"in_flight_limit": self.background_inflight_limit()
})),
);
} else {
out.push(
GuardResult::warn(
g.name(),
"background guard dropped due to in-flight limit",
)
.with_details(serde_json::json!({
"background": true,
"note": "dropped",
"in_flight_limit": self.background_inflight_limit(),
"dropped_total": self.background_dropped_count()
})),
);
}
}
}

Expand All @@ -228,9 +277,20 @@ impl AsyncGuardRuntime {
guard: Arc<dyn AsyncGuard>,
action: OwnedGuardAction,
context: GuardContext,
) {
) -> bool {
let permit = match self.background_slots.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
self.background_dropped.fetch_add(1, Ordering::Relaxed);
return false;
}
};

let runtime = Arc::clone(self);
tokio::spawn(async move {
let running = runtime.background_running.fetch_add(1, Ordering::Relaxed) + 1;
update_max(&runtime.background_peak_running, running);

let borrowed = action.as_guard_action();
let result = runtime
.evaluate_one(guard.clone(), &borrowed, &context)
Expand All @@ -245,7 +305,11 @@ impl AsyncGuardRuntime {
"background async guard would have denied"
);
}

runtime.background_running.fetch_sub(1, Ordering::Relaxed);
drop(permit);
});
true
}

async fn evaluate_one(
Expand Down Expand Up @@ -390,6 +454,21 @@ impl AsyncGuardRuntime {
}
}

fn update_max(target: &AtomicUsize, candidate: usize) {
loop {
let current = target.load(Ordering::Relaxed);
if candidate <= current {
return;
}
if target
.compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
return;
}
}
}

fn fallback(
guard: &str,
cfg: &AsyncGuardConfig,
Expand Down
120 changes: 116 additions & 4 deletions crates/libs/clawdstrike/src/guards/forbidden_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use async_trait::async_trait;
use glob::Pattern;
use serde::{Deserialize, Serialize};

use super::path_normalization::normalize_path_for_policy;
use super::path_normalization::{
normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
normalize_path_for_policy_with_fs,
};
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};

/// Configuration for ForbiddenPathGuard
Expand Down Expand Up @@ -189,18 +192,40 @@ impl ForbiddenPathGuard {

/// Check if a path is forbidden
pub fn is_forbidden(&self, path: &str) -> bool {
let path = normalize_path_for_policy(path);
let lexical_path = normalize_path_for_policy(path);
let resolved_path = normalize_path_for_policy_with_fs(path);
let lexical_abs_path = normalize_path_for_policy_lexical_absolute(path);
let resolved_differs_from_lexical_target = lexical_abs_path
.as_deref()
.map(|abs| abs != resolved_path.as_str())
.unwrap_or(resolved_path != lexical_path);

// Check exceptions first
for exception in &self.exceptions {
if exception.matches(&path) {
let lexical_matches = exception.matches(&lexical_path)
|| lexical_abs_path
.as_deref()
.map(|abs| exception.matches(abs))
.unwrap_or(false);
let resolved_matches = exception.matches(&resolved_path);
let exception_matches = if resolved_differs_from_lexical_target {
// If resolution changed the actual target (for example via symlink traversal),
// require the exception to match the resolved target to prevent lexical bypasses.
resolved_matches
} else {
// If target identity is unchanged (for example relative -> absolute conversion),
// allow either lexical or resolved exception forms.
resolved_matches || lexical_matches
};

if exception_matches {
return false;
}
}

// Check forbidden patterns
for pattern in &self.patterns {
if pattern.matches(&path) {
if pattern.matches(&resolved_path) || pattern.matches(&lexical_path) {
return true;
}
}
Expand Down Expand Up @@ -299,6 +324,29 @@ mod tests {
assert!(!guard.is_forbidden("/app/project/.env"));
}

#[test]
fn relative_exception_matches_when_target_is_unchanged() {
let rel_dir = format!("target/forbidden-path-rel-{}", uuid::Uuid::new_v4());
std::fs::create_dir_all(&rel_dir).expect("create rel dir");
let rel_file = format!("{rel_dir}/.env");
std::fs::write(&rel_file, "API_KEY=test\n").expect("write file");

let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
enabled: true,
patterns: Some(vec!["**/.env".to_string()]),
exceptions: vec![rel_file.clone()],
additional_patterns: vec![],
remove_patterns: vec![],
});

assert!(
!guard.is_forbidden(&rel_file),
"relative exception should match even when fs normalization produces absolute path"
);

let _ = std::fs::remove_dir_all(&rel_dir);
}

#[test]
fn test_additional_patterns_field() {
let yaml = r#"
Expand Down Expand Up @@ -356,4 +404,68 @@ remove_patterns:
.await;
assert!(result.allowed);
}

#[cfg(unix)]
#[test]
fn symlink_target_matching_forbidden_pattern_is_forbidden() {
use std::os::unix::fs::symlink;

let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4()));
let safe_dir = root.join("safe");
let forbidden_dir = root.join("forbidden");
std::fs::create_dir_all(&safe_dir).expect("create safe dir");
std::fs::create_dir_all(&forbidden_dir).expect("create forbidden dir");

let target = forbidden_dir.join("secret.txt");
std::fs::write(&target, "secret").expect("write target");
let link = safe_dir.join("link.txt");
symlink(&target, &link).expect("create symlink");

let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
enabled: true,
patterns: Some(vec!["**/forbidden/**".to_string()]),
exceptions: vec![],
additional_patterns: vec![],
remove_patterns: vec![],
});

assert!(
guard.is_forbidden(link.to_str().expect("utf-8 path")),
"symlink target that resolves into forbidden path must be blocked"
);

let _ = std::fs::remove_dir_all(&root);
}

#[cfg(unix)]
#[test]
fn lexical_exception_does_not_bypass_forbidden_resolved_target() {
use std::os::unix::fs::symlink;

let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4()));
let safe_dir = root.join("safe");
let forbidden_dir = root.join("forbidden");
std::fs::create_dir_all(&safe_dir).expect("create safe dir");
std::fs::create_dir_all(&forbidden_dir).expect("create forbidden dir");

let target = forbidden_dir.join("secret.env");
std::fs::write(&target, "secret").expect("write target");
let link = safe_dir.join("project.env");
symlink(&target, &link).expect("create symlink");

let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
enabled: true,
patterns: Some(vec!["**/forbidden/**".to_string()]),
exceptions: vec!["**/safe/project.env".to_string()],
additional_patterns: vec![],
remove_patterns: vec![],
});

assert!(
guard.is_forbidden(link.to_str().expect("utf-8 path")),
"lexical-only exception should not bypass when resolved target is forbidden"
);

let _ = std::fs::remove_dir_all(&root);
}
}
Loading