From 93456c94528828933895639b39d17990fcc69c5c Mon Sep 17 00:00:00 2001 From: Kriday Dave Date: Sun, 8 Nov 2026 14:21:54 +0530 Subject: [PATCH] visibility: fix multi-wildcard matching and catch *.pem keys (SEC-01, SEC-02) SEC-02: matches_pattern used split_once('*') so patterns with more than one wildcard (e.g. *key*, *audit*, a*b*c) never matched real paths/branches. Replace with segment-wise matching that handles any number of '*' wildcards. SEC-01: private key files like key.pem/deploy/id.pem were not caught by the repo visibility policy. visibility.toml documented '*.pem' as the suffix form while the config used literal '.pem'; switch the policy to '*.pem' (matching documented intent) and add a regression test covering multi-* globs and the *.pem suffix case. --- src/visibility.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++--- visibility.toml | 2 +- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/visibility.rs b/src/visibility.rs index 5af7850..ea3749e 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -82,11 +82,39 @@ fn matches_pattern(candidate: &str, pattern: &str) -> bool { if pattern == "*" { return true; } - if let Some((prefix, suffix)) = pattern.split_once('*') { - candidate.starts_with(prefix) && candidate[prefix.len()..].ends_with(suffix) - } else { - candidate == pattern + if !pattern.contains('*') { + return candidate == pattern; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let first = parts[0]; + let last = parts[parts.len() - 1]; + + if !candidate.starts_with(first) || !candidate.ends_with(last) { + return false; + } + + let mut rest = &candidate[first.len()..]; + let Some(tail_start) = rest.len().checked_sub(last.len()) else { + return false; + }; + if tail_start == 0 && parts.len() == 2 { + return true; } + // Consume the fixed tail so middle segments cannot overlap it. + rest = &rest[..tail_start]; + + for segment in &parts[1..parts.len() - 1] { + if segment.is_empty() { + continue; + } + let Some(pos) = rest.find(segment) else { + return false; + }; + rest = &rest[pos + segment.len()..]; + } + + true } /// Whether `candidate` matches one declared private-branch `pattern`. @@ -477,4 +505,51 @@ mod tests { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } + + #[test] + fn test_path_globs_support_multiple_wildcards_and_pem_suffix() { + // SEC-02: multi-* globs like *key* must match real paths/branches. + // SEC-01 (documented intent): *.pem is the suffix form for key files. + let policy = VisibilityPolicy { + private_paths: vec!["*.pem".into(), "*key*".into(), "a*b*c".into()], + embargo_until: None, + private_branches: vec!["*audit*".into(), "wip/*/secret".into()], + resign_key_id: None, + embargo_recipients: vec![], + }; + + for path in [ + "key.pem", + "deploy/id.pem", + "certs/server.pem", + "src/secret-key", + "keyring/data.txt", + "aXbYc", + ] { + assert!( + policy.path_is_private(path), + "path {path} must match a private glob" + ); + } + assert!( + !policy.path_is_private("src/main.rs"), + "unrelated path must not match" + ); + // Single-* glob still works. + let single = VisibilityPolicy { + private_paths: vec!["logs/*.log".into()], + embargo_until: None, + private_branches: vec!["private/*".into()], + resign_key_id: None, + embargo_recipients: vec![], + }; + assert!(single.path_is_private("logs/app.log")); + assert!(single.branch_is_private("private/feature")); + assert!(!single.branch_is_private("public/feature")); + + assert!(policy.branch_is_private("feature-audit")); + assert!(policy.branch_is_private("my-audit-branch")); + assert!(policy.branch_is_private("wip/team/secret")); + assert!(!policy.branch_is_private("feature/public")); + } } diff --git a/visibility.toml b/visibility.toml index 201a1c7..69a359f 100644 --- a/visibility.toml +++ b/visibility.toml @@ -14,7 +14,7 @@ # `config/.env` and `config/.env.local`; `secrets/` matches anything below # `secrets/`; `*` is the only wildcard and `.pem` matches a file literally # named `.pem`, not `server.pem`. Use `*.pem` for a suffix. -private_paths = [".env", "secrets/", ".pem"] +private_paths = [".env", "secrets/", "*.pem"] # No embargo schedule for this repo yet; add `embargo_until = "YYYY-MM-DD"` # to hold all changes until that date. Public export refuses under embargo.