everruns_core/
system_allowlist.rs1use crate::network_access::NetworkAccessList;
16use serde::Deserialize;
17use std::collections::BTreeMap;
18use std::sync::{Arc, OnceLock};
19
20pub const SYSTEM_ALLOWLIST_ENABLED_ENV: &str = "EVERRUNS_SYSTEM_ALLOWLIST_ENABLED";
22
23const EMBEDDED_TOML: &str = include_str!("system_allowlist.toml");
25
26#[derive(Debug, Clone, Deserialize)]
27struct AllowlistFile {
28 #[serde(default)]
29 groups: BTreeMap<String, GroupSpec>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33struct GroupSpec {
34 #[serde(default)]
35 description: Option<String>,
36 #[serde(default)]
37 allowed: Vec<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct AllowGroup {
43 pub name: String,
44 pub description: Option<String>,
45 pub allowed: Vec<String>,
46}
47
48#[derive(Debug, Clone)]
54pub struct SystemAllowlist {
55 groups: Vec<AllowGroup>,
56 acl: NetworkAccessList,
57}
58
59impl SystemAllowlist {
60 pub fn from_toml(source: &str) -> Result<Self, toml::de::Error> {
62 let file: AllowlistFile = toml::from_str(source)?;
63 let mut groups = Vec::with_capacity(file.groups.len());
64 let mut patterns = Vec::new();
65 for (name, spec) in file.groups {
66 patterns.extend(spec.allowed.iter().cloned());
67 groups.push(AllowGroup {
68 name,
69 description: spec.description,
70 allowed: spec.allowed,
71 });
72 }
73 let acl = if patterns.is_empty() {
79 NetworkAccessList::allow_only(["<none>"])
80 } else {
81 NetworkAccessList::allow_only(patterns)
82 };
83 Ok(Self { groups, acl })
84 }
85
86 pub fn embedded() -> Arc<SystemAllowlist> {
88 static EMBEDDED: OnceLock<Arc<SystemAllowlist>> = OnceLock::new();
89 EMBEDDED
90 .get_or_init(|| {
91 Arc::new(
92 SystemAllowlist::from_toml(EMBEDDED_TOML)
93 .expect("embedded system_allowlist.toml is valid"),
94 )
95 })
96 .clone()
97 }
98
99 pub fn from_env() -> Option<Arc<SystemAllowlist>> {
104 let enabled = std::env::var(SYSTEM_ALLOWLIST_ENABLED_ENV)
105 .map(|value| value == "true" || value == "1")
106 .unwrap_or(false);
107 enabled.then(SystemAllowlist::embedded)
108 }
109
110 pub fn groups(&self) -> &[AllowGroup] {
112 &self.groups
113 }
114
115 pub fn is_url_allowed(&self, url: &str) -> bool {
117 self.acl.is_url_allowed(url)
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn embedded_allowlist_parses_and_has_groups() {
127 let allowlist = SystemAllowlist::embedded();
128 assert!(
129 !allowlist.groups().is_empty(),
130 "embedded allowlist should define groups"
131 );
132 for group in allowlist.groups() {
134 assert!(
135 !group.allowed.is_empty(),
136 "group {} has no patterns",
137 group.name
138 );
139 }
140 }
141
142 #[test]
143 fn embedded_allowlist_permits_known_public_resources() {
144 let allowlist = SystemAllowlist::embedded();
145 for url in [
146 "https://registry.npmjs.org/left-pad",
147 "https://static.crates.io/crates/serde/serde-1.0.0.crate",
148 "https://files.pythonhosted.org/packages/abc.whl",
149 "https://api.openai.com/v1/responses",
150 "https://api.anthropic.com/v1/messages",
151 "https://codeload.github.com/owner/repo/tar.gz/main",
152 "https://ghcr.io/v2/owner/image/manifests/latest",
153 "https://gcp-us-central1.turbopuffer.com/v2/namespaces/org_1__kidx_a",
154 ] {
155 assert!(allowlist.is_url_allowed(url), "should allow {url}");
156 }
157 }
158
159 #[test]
160 fn embedded_allowlist_denies_unlisted_hosts() {
161 let allowlist = SystemAllowlist::embedded();
162 for url in [
163 "https://evil.example.com/payload",
164 "http://169.254.169.254/latest/meta-data/",
165 "https://random-blog.net/post",
166 ] {
167 assert!(!allowlist.is_url_allowed(url), "should deny {url}");
168 }
169 }
170
171 #[test]
172 fn embedded_allowlist_permits_azure_openai_hosts() {
173 let allowlist = SystemAllowlist::embedded();
174 for url in [
175 "https://my-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions",
176 "https://my-resource.services.ai.azure.com/openai/v1/responses",
177 ] {
178 assert!(allowlist.is_url_allowed(url), "should permit {url}");
179 }
180 }
181
182 #[test]
183 fn embedded_allowlist_denies_attacker_controlled_provider_hosts() {
184 let allowlist = SystemAllowlist::embedded();
185 for url in [
186 "https://attacker123.execute-api.us-east-1.amazonaws.com/collect?data=secret",
187 "https://evil-bucket.s3.us-west-2.amazonaws.com/collect?data=secret",
188 "https://evil-bucket.s3-website-us-west-2.amazonaws.com/collect?data=secret",
189 "https://attacker-org.github.io/collect?data=secret",
190 "https://evil.z13.web.core.windows.net/collect?data=secret",
191 "https://evil.azureedge.net/collect?data=secret",
192 "https://evil-bucket.nyc3.digitaloceanspaces.com/collect?data=secret",
193 ] {
194 assert!(!allowlist.is_url_allowed(url), "should deny {url}");
195 }
196 }
197
198 #[test]
199 fn empty_allowlist_fails_closed() {
200 for source in ["", "[groups.empty]\n", "[groups.empty]\nallowed = []\n"] {
203 let allowlist = SystemAllowlist::from_toml(source).expect("valid toml");
204 assert!(
205 !allowlist.is_url_allowed("https://example.com/"),
206 "empty allowlist (source: {source:?}) must deny all URLs"
207 );
208 }
209 }
210
211 #[test]
212 fn from_toml_flattens_group_patterns() {
213 let allowlist = SystemAllowlist::from_toml(
214 r#"
215 [groups.alpha]
216 description = "first"
217 allowed = ["*.alpha.test"]
218
219 [groups.beta]
220 allowed = ["beta.test"]
221 "#,
222 )
223 .expect("valid toml");
224
225 assert_eq!(allowlist.groups().len(), 2);
226 assert!(allowlist.is_url_allowed("https://api.alpha.test/x"));
227 assert!(allowlist.is_url_allowed("https://beta.test/y"));
228 assert!(!allowlist.is_url_allowed("https://gamma.test/z"));
229 }
230}