Skip to content

Commit e0d88a8

Browse files
committed
fix(irm): accept bare filenames in path extraction
Also defer git host IP DNS checks until after cache hit checks in hush-cli remote extends, with regression coverage for offline cached git policy resolution.
1 parent e6cc36a commit e0d88a8

File tree

3 files changed

+97
-3
lines changed

3 files changed

+97
-3
lines changed

crates/libs/clawdstrike/src/irm/fs.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,24 @@ impl FilesystemIrm {
135135

136136
/// Extract path from host call arguments
137137
fn extract_path(&self, call: &HostCall) -> Option<String> {
138+
let allow_bare_string_paths = call.function.contains("path");
139+
138140
for arg in &call.args {
139141
if let Some(s) = arg.as_str() {
140-
if self.looks_like_path(s) {
142+
if self.looks_like_path(s)
143+
|| (allow_bare_string_paths && self.looks_like_bare_filename(s))
144+
{
141145
return Some(s.to_string());
142146
}
143147
}
144148

145149
if let Some(obj) = arg.as_object() {
146150
for key in ["path", "file_path", "target_path"] {
147151
if let Some(path) = obj.get(key).and_then(|value| value.as_str()) {
148-
if self.looks_like_path(path) || self.has_parent_traversal(path) {
152+
if self.looks_like_path(path)
153+
|| self.has_parent_traversal(path)
154+
|| self.looks_like_bare_filename(path)
155+
{
149156
return Some(path.to_string());
150157
}
151158
}
@@ -176,6 +183,31 @@ impl FilesystemIrm {
176183
value.contains('/') && !value.contains("://")
177184
}
178185

186+
fn looks_like_bare_filename(&self, value: &str) -> bool {
187+
let value = value.trim();
188+
if value.is_empty() {
189+
return false;
190+
}
191+
192+
if value.contains("://") {
193+
return false;
194+
}
195+
196+
if value == "." || value == ".." {
197+
return false;
198+
}
199+
200+
if value.contains('/') || value.contains('\\') {
201+
return false;
202+
}
203+
204+
if value.bytes().all(|b| b.is_ascii_digit()) {
205+
return false;
206+
}
207+
208+
!value.chars().any(|ch| ch.is_control())
209+
}
210+
179211
fn has_parent_traversal(&self, path: &str) -> bool {
180212
path.replace('\\', "/").split('/').any(|seg| seg == "..")
181213
}
@@ -346,10 +378,32 @@ mod tests {
346378
Some("../../etc/passwd".to_string())
347379
);
348380

381+
let call = HostCall::new("path_open", vec![serde_json::json!("README.md")]);
382+
assert_eq!(irm.extract_path(&call), Some("README.md".to_string()));
383+
384+
let call = HostCall::new(
385+
"fd_write",
386+
vec![serde_json::json!({"target_path": "config.json"})],
387+
);
388+
assert_eq!(irm.extract_path(&call), Some("config.json".to_string()));
389+
349390
let call = HostCall::new("fd_read", vec![serde_json::json!(123)]);
350391
assert_eq!(irm.extract_path(&call), None);
351392
}
352393

394+
#[tokio::test]
395+
async fn filesystem_irm_allows_bare_filename_for_path_style_calls() {
396+
let irm = FilesystemIrm::new();
397+
let policy = Policy::default();
398+
let call = HostCall::new("path_open", vec![serde_json::json!("README.md")]);
399+
let decision = irm.evaluate(&call, &policy).await;
400+
401+
assert!(
402+
decision.is_allowed(),
403+
"bare filename should be treated as a valid filesystem path in path-style calls"
404+
);
405+
}
406+
353407
#[tokio::test]
354408
async fn filesystem_irm_denies_parent_traversal_relative_paths() {
355409
let irm = FilesystemIrm::new();

crates/services/hush-cli/src/remote_extends.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,6 @@ impl RemotePolicyResolver {
375375

376376
let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?;
377377
self.ensure_host_allowed(&repo_host)?;
378-
self.ensure_git_host_ip_policy(&repo_host)?;
379378

380379
if !self.cfg.remote_enabled() {
381380
return Err(Error::ConfigError(
@@ -403,6 +402,7 @@ impl RemotePolicyResolver {
403402
let _ = std::fs::remove_file(&cache_path);
404403
}
405404

405+
self.ensure_git_host_ip_policy(&repo_host)?;
406406
let bytes = self.git_show_file(repo, commit, path)?;
407407
verify_sha256_pin(&bytes, expected_sha)?;
408408
self.write_cache(&cache_path, &bytes)?;

crates/services/hush-cli/src/tests.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3143,6 +3143,46 @@ extends: {}#sha256={}
31433143
);
31443144
}
31453145

3146+
#[test]
3147+
fn remote_extends_git_cache_hit_does_not_require_dns_resolution() {
3148+
let cache_dir = std::env::temp_dir().join(format!(
3149+
"hush-cli-remote-extends-test-{}",
3150+
uuid::Uuid::new_v4()
3151+
));
3152+
std::fs::create_dir_all(&cache_dir).expect("create cache dir");
3153+
3154+
let repo = "https://offline-cache.example.invalid/org/repo.git";
3155+
let commit = "deadbeef";
3156+
let path = "policy.yaml";
3157+
let yaml_bytes = br#"
3158+
version: "1.1.0"
3159+
name: cached
3160+
settings:
3161+
fail_fast: true
3162+
"#;
3163+
let expected_sha = sha256(yaml_bytes).to_hex();
3164+
let key = format!("git:{}@{}:{}#sha256={}", repo, commit, path, expected_sha);
3165+
let digest = sha256(key.as_bytes()).to_hex();
3166+
let cache_path = cache_dir.join(format!("{}.yaml", digest));
3167+
std::fs::write(&cache_path, yaml_bytes).expect("write cached bytes");
3168+
3169+
let cfg = RemoteExtendsConfig::new(["offline-cache.example.invalid".to_string()])
3170+
.with_cache_dir(&cache_dir)
3171+
.with_allow_private_ips(false);
3172+
let resolver = RemotePolicyResolver::new(cfg).expect("resolver");
3173+
let reference = format!("git+{}@{}:{}#sha256={}", repo, commit, path, expected_sha);
3174+
3175+
let resolved = resolver
3176+
.resolve(&reference, &PolicyLocation::None)
3177+
.expect("cached git policy should resolve without DNS");
3178+
assert!(
3179+
resolved.yaml.contains("name: cached"),
3180+
"expected cached YAML payload"
3181+
);
3182+
3183+
let _ = std::fs::remove_dir_all(&cache_dir);
3184+
}
3185+
31463186
#[test]
31473187
fn remote_extends_resolves_relative_urls() {
31483188
let nested = br#"

0 commit comments

Comments
 (0)