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 ] {
154 assert!(allowlist.is_url_allowed(url), "should allow {url}");
155 }
156 }
157
158 #[test]
159 fn embedded_allowlist_denies_unlisted_hosts() {
160 let allowlist = SystemAllowlist::embedded();
161 for url in [
162 "https://evil.example.com/payload",
163 "http://169.254.169.254/latest/meta-data/",
164 "https://random-blog.net/post",
165 ] {
166 assert!(!allowlist.is_url_allowed(url), "should deny {url}");
167 }
168 }
169
170 #[test]
171 fn empty_allowlist_fails_closed() {
172 for source in ["", "[groups.empty]\n", "[groups.empty]\nallowed = []\n"] {
175 let allowlist = SystemAllowlist::from_toml(source).expect("valid toml");
176 assert!(
177 !allowlist.is_url_allowed("https://example.com/"),
178 "empty allowlist (source: {source:?}) must deny all URLs"
179 );
180 }
181 }
182
183 #[test]
184 fn from_toml_flattens_group_patterns() {
185 let allowlist = SystemAllowlist::from_toml(
186 r#"
187 [groups.alpha]
188 description = "first"
189 allowed = ["*.alpha.test"]
190
191 [groups.beta]
192 allowed = ["beta.test"]
193 "#,
194 )
195 .expect("valid toml");
196
197 assert_eq!(allowlist.groups().len(), 2);
198 assert!(allowlist.is_url_allowed("https://api.alpha.test/x"));
199 assert!(allowlist.is_url_allowed("https://beta.test/y"));
200 assert!(!allowlist.is_url_allowed("https://gamma.test/z"));
201 }
202}