microsandbox_network/secrets/
config.rs1use serde::{Deserialize, Serialize};
4
5pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct SecretsConfig {
19 #[serde(default)]
21 pub secrets: Vec<SecretEntry>,
22
23 #[serde(default)]
25 pub on_violation: ViolationAction,
26}
27
28#[derive(Clone, Serialize, Deserialize)]
30pub struct SecretEntry {
31 pub env_var: String,
37
38 pub value: String,
40
41 pub placeholder: String,
46
47 #[serde(default)]
49 pub allowed_hosts: Vec<HostPattern>,
50
51 #[serde(default)]
53 pub injection: SecretInjection,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub on_violation: Option<ViolationAction>,
58
59 #[serde(default = "default_true")]
63 pub require_tls_identity: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum HostPattern {
70 #[serde(alias = "Exact")]
72 Exact(String),
73 #[serde(alias = "Wildcard")]
75 Wildcard(String),
76 #[serde(alias = "Any")]
78 Any,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83pub enum SecretConfigError {
84 #[error("secret #{secret_index}: env_var must not be empty")]
86 EmptyEnvVar {
87 secret_index: usize,
89 },
90
91 #[error("secret #{secret_index}: env_var must not contain `=`")]
93 EnvVarContainsEquals {
94 secret_index: usize,
96 },
97
98 #[error("secret #{secret_index}: env_var must not contain NUL")]
100 EnvVarContainsNul {
101 secret_index: usize,
103 },
104
105 #[error("secret #{secret_index}: at least one allowed host is required")]
107 MissingAllowedHosts {
108 secret_index: usize,
110 },
111
112 #[error("secret #{secret_index}: placeholder must not be empty")]
114 EmptyPlaceholder {
115 secret_index: usize,
117 },
118
119 #[error(
121 "secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
122 )]
123 PlaceholderTooLong {
124 secret_index: usize,
126 actual_bytes: usize,
128 max_bytes: usize,
130 },
131
132 #[error("secret #{secret_index}: placeholder must not contain NUL")]
134 PlaceholderContainsNul {
135 secret_index: usize,
137 },
138
139 #[error("secret #{secret_index}: placeholder must not contain CR or LF")]
141 PlaceholderContainsLineBreak {
142 secret_index: usize,
144 },
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SecretInjection {
150 #[serde(default = "default_true")]
152 pub headers: bool,
153
154 #[serde(default = "default_true")]
156 pub basic_auth: bool,
157
158 #[serde(default)]
160 pub query_params: bool,
161
162 #[serde(default)]
170 pub body: bool,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "kebab-case")]
176pub enum ViolationAction {
177 #[serde(alias = "Block")]
179 Block,
180 #[default]
182 #[serde(alias = "BlockAndLog", alias = "block_and_log")]
183 BlockAndLog,
184 #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
186 BlockAndTerminate,
187 #[serde(alias = "Passthrough")]
189 Passthrough(Vec<HostPattern>),
190}
191
192impl SecretsConfig {
197 pub fn validate(&self) -> Result<(), SecretConfigError> {
199 for (index, secret) in self.secrets.iter().enumerate() {
200 secret.validate(index)?;
201 }
202 Ok(())
203 }
204}
205
206impl SecretEntry {
207 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
209 validate_env_var(&self.env_var, secret_index)?;
210
211 if self.allowed_hosts.is_empty() {
212 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
213 }
214
215 validate_placeholder(&self.placeholder, secret_index)
216 }
217}
218
219impl std::fmt::Debug for SecretEntry {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("SecretEntry")
222 .field("env_var", &self.env_var)
223 .field("value", &"[REDACTED]")
224 .field("placeholder", &self.placeholder)
225 .field("allowed_hosts", &self.allowed_hosts)
226 .field("injection", &self.injection)
227 .field("on_violation", &self.on_violation)
228 .field("require_tls_identity", &self.require_tls_identity)
229 .finish()
230 }
231}
232
233impl HostPattern {
234 pub fn matches(&self, hostname: &str) -> bool {
239 match self {
240 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
241 HostPattern::Wildcard(pattern) => {
242 if let Some(suffix) = pattern.strip_prefix("*.") {
243 hostname.eq_ignore_ascii_case(suffix)
244 || (hostname.len() > suffix.len() + 1
245 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
246 && hostname[hostname.len() - suffix.len()..]
247 .eq_ignore_ascii_case(suffix))
248 } else {
249 hostname.eq_ignore_ascii_case(pattern)
250 }
251 }
252 HostPattern::Any => true,
253 }
254 }
255}
256
257impl Default for SecretInjection {
262 fn default() -> Self {
263 Self {
264 headers: true,
265 basic_auth: true,
266 query_params: false,
267 body: false,
268 }
269 }
270}
271
272fn default_true() -> bool {
277 true
278}
279
280fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
281 if env_var.is_empty() {
282 return Err(SecretConfigError::EmptyEnvVar { secret_index });
283 }
284 if env_var.contains('=') {
285 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
286 }
287 if env_var.contains('\0') {
288 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
289 }
290 Ok(())
291}
292
293fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
294 if placeholder.is_empty() {
295 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
296 }
297
298 let actual_bytes = placeholder.len();
299 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
300 return Err(SecretConfigError::PlaceholderTooLong {
301 secret_index,
302 actual_bytes,
303 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
304 });
305 }
306
307 if placeholder.contains('\0') {
308 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
309 }
310 if placeholder.contains('\r') || placeholder.contains('\n') {
311 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
312 }
313
314 Ok(())
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324
325 fn valid_secret() -> SecretEntry {
326 SecretEntry {
327 env_var: "API_KEY".into(),
328 value: "secret".into(),
329 placeholder: "$MSB_API_KEY".into(),
330 allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
331 injection: SecretInjection::default(),
332 on_violation: None,
333 require_tls_identity: true,
334 }
335 }
336
337 #[test]
338 fn exact_host_match() {
339 let p = HostPattern::Exact("api.openai.com".into());
340 assert!(p.matches("api.openai.com"));
341 assert!(p.matches("API.OpenAI.com"));
342 assert!(!p.matches("evil.com"));
343 }
344
345 #[test]
346 fn wildcard_host_match() {
347 let p = HostPattern::Wildcard("*.openai.com".into());
348 assert!(p.matches("api.openai.com"));
349 assert!(p.matches("openai.com"));
350 assert!(!p.matches("evil.com"));
351 }
352
353 #[test]
354 fn any_host_match() {
355 let p = HostPattern::Any;
356 assert!(p.matches("anything.com"));
357 }
358
359 #[test]
360 fn default_injection_scopes() {
361 let inj = SecretInjection::default();
362 assert!(inj.headers);
363 assert!(inj.basic_auth);
364 assert!(!inj.query_params);
365 assert!(!inj.body);
366 }
367
368 #[test]
369 fn default_require_tls_identity() {
370 let entry = SecretEntry {
371 env_var: "K".into(),
372 value: "v".into(),
373 placeholder: "$K".into(),
374 allowed_hosts: vec![],
375 injection: SecretInjection::default(),
376 on_violation: None,
377 require_tls_identity: true,
378 };
379 assert!(entry.require_tls_identity);
380 }
381
382 #[test]
383 fn secret_validation_accepts_linux_environment_name_shape() {
384 let mut entry = valid_secret();
385 entry.env_var = "1TOKEN.with-dashes".into();
386
387 assert!(entry.validate(0).is_ok());
388 }
389
390 #[test]
391 fn secret_validation_rejects_invalid_env_var_names() {
392 let cases = [
393 ("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
394 (
395 "API=KEY",
396 SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
397 ),
398 (
399 "API\0KEY",
400 SecretConfigError::EnvVarContainsNul { secret_index: 0 },
401 ),
402 ];
403
404 for (env_var, expected) in cases {
405 let mut entry = valid_secret();
406 entry.env_var = env_var.into();
407 assert_eq!(entry.validate(0), Err(expected));
408 }
409 }
410
411 #[test]
412 fn secret_validation_rejects_missing_allowed_hosts() {
413 let mut entry = valid_secret();
414 entry.allowed_hosts.clear();
415
416 assert_eq!(
417 entry.validate(0),
418 Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
419 );
420 }
421
422 #[test]
423 fn secret_validation_rejects_invalid_placeholders() {
424 let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
425 let cases = [
426 ("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
427 (
428 too_long.as_str(),
429 SecretConfigError::PlaceholderTooLong {
430 secret_index: 0,
431 actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
432 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
433 },
434 ),
435 (
436 "abc\0def",
437 SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
438 ),
439 (
440 "abc\rdef",
441 SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
442 ),
443 (
444 "abc\ndef",
445 SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
446 ),
447 ];
448
449 for (placeholder, expected) in cases {
450 let mut entry = valid_secret();
451 entry.placeholder = placeholder.into();
452 assert_eq!(entry.validate(0), Err(expected));
453 }
454 }
455
456 #[test]
457 fn violation_action_serializes_with_sdk_casing() {
458 let action = ViolationAction::Passthrough(vec![
459 HostPattern::Exact("api.anthropic.com".into()),
460 HostPattern::Wildcard("*.anthropic.com".into()),
461 HostPattern::Any,
462 ]);
463
464 assert_eq!(
465 serde_json::to_string(&action).unwrap(),
466 r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
467 );
468 assert_eq!(
469 serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
470 r#""block-and-log""#
471 );
472 assert_eq!(
473 serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
474 r#""block-and-terminate""#
475 );
476 }
477
478 #[test]
479 fn violation_action_accepts_legacy_pascal_case() {
480 let action: ViolationAction =
481 serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
482
483 assert_eq!(
484 action,
485 ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
486 );
487 assert_eq!(
488 serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
489 ViolationAction::BlockAndTerminate
490 );
491 }
492}