agentshield/egress/policy/domain.rs
1use serde::{Deserialize, Serialize};
2
3/// Domain-level allow/deny policy using glob-style patterns.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct DomainPolicy {
6 /// Allowed domain patterns (glob-style: `"*.example.com"`, `"api.github.com"`).
7 #[serde(default)]
8 pub allow: Vec<String>,
9 /// Explicitly denied domain patterns (takes precedence over allow).
10 #[serde(default)]
11 pub deny: Vec<String>,
12}
13
14impl DomainPolicy {
15 /// Check if a domain is allowed by this policy.
16 ///
17 /// Deny rules take precedence over allow rules. If the allow list is
18 /// empty, all domains not explicitly denied are allowed.
19 pub(super) fn is_domain_allowed(&self, domain: &str) -> bool {
20 // Deny takes precedence
21 if self
22 .deny
23 .iter()
24 .any(|pattern| domain_matches(domain, pattern))
25 {
26 return false;
27 }
28 // If allow list is empty, allow all (that aren't denied)
29 if self.allow.is_empty() {
30 return true;
31 }
32 self.allow
33 .iter()
34 .any(|pattern| domain_matches(domain, pattern))
35 }
36}
37
38/// Simple glob matching for domain patterns.
39///
40/// Supports `*.example.com` (matches `sub.example.com` and `example.com`)
41/// and exact matches like `api.github.com`.
42pub(super) fn domain_matches(domain: &str, pattern: &str) -> bool {
43 if let Some(suffix) = pattern.strip_prefix('*') {
44 // "*.example.com" matches "sub.example.com" and "example.com"
45 domain.ends_with(suffix) || domain == &suffix[1..]
46 } else {
47 domain == pattern
48 }
49}
50
51/// Extract the hostname from a URL string or bare domain.
52///
53/// Handles `http://`, `https://` URLs (strips scheme, path, port) and bare
54/// domain names (e.g., `"api.example.com"`). Returns `None` for strings that
55/// cannot be mapped to a useful hostname (e.g., paths, IP-like without dot).
56pub(super) fn extract_domain(url_or_domain: &str) -> Option<String> {
57 // Try stripping http:// or https://
58 let rest = if let Some(r) = url_or_domain.strip_prefix("https://") {
59 r
60 } else if let Some(r) = url_or_domain.strip_prefix("http://") {
61 r
62 } else {
63 // Bare domain: must contain a dot and no slashes
64 if url_or_domain.contains('.') && !url_or_domain.contains('/') {
65 return Some(url_or_domain.to_string());
66 }
67 return None;
68 };
69
70 // Take the host portion (before first '/')
71 let host = rest.split('/').next()?;
72 // Strip port if present
73 let host = host.split(':').next()?;
74
75 if host.is_empty() {
76 return None;
77 }
78 Some(host.to_string())
79}