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 pub(crate) fn has_plain_http_candidates(&self) -> bool {
211 self.secrets.iter().any(|secret| {
212 !secret.require_tls_identity
213 && (secret.injection.headers
214 || secret.injection.basic_auth
215 || secret.injection.query_params
216 || secret.injection.body)
217 })
218 }
219
220 pub(crate) fn has_host_scoped_secrets(&self) -> bool {
226 self.secrets
227 .iter()
228 .any(|secret| secret.allowed_hosts.iter().any(|h| *h != HostPattern::Any))
229 }
230}
231
232impl SecretEntry {
233 pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
235 validate_env_var(&self.env_var, secret_index)?;
236
237 if self.allowed_hosts.is_empty() {
238 return Err(SecretConfigError::MissingAllowedHosts { secret_index });
239 }
240
241 validate_placeholder(&self.placeholder, secret_index)
242 }
243}
244
245impl std::fmt::Debug for SecretEntry {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 f.debug_struct("SecretEntry")
248 .field("env_var", &self.env_var)
249 .field("value", &"[REDACTED]")
250 .field("placeholder", &self.placeholder)
251 .field("allowed_hosts", &self.allowed_hosts)
252 .field("injection", &self.injection)
253 .field("on_violation", &self.on_violation)
254 .field("require_tls_identity", &self.require_tls_identity)
255 .finish()
256 }
257}
258
259impl HostPattern {
260 pub fn matches(&self, hostname: &str) -> bool {
265 match self {
266 HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
267 HostPattern::Wildcard(pattern) => {
268 if let Some(suffix) = pattern.strip_prefix("*.") {
269 hostname.eq_ignore_ascii_case(suffix)
270 || (hostname.len() > suffix.len() + 1
271 && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
272 && hostname[hostname.len() - suffix.len()..]
273 .eq_ignore_ascii_case(suffix))
274 } else {
275 hostname.eq_ignore_ascii_case(pattern)
276 }
277 }
278 HostPattern::Any => true,
279 }
280 }
281}
282
283impl Default for SecretInjection {
288 fn default() -> Self {
289 Self {
290 headers: true,
291 basic_auth: true,
292 query_params: false,
293 body: false,
294 }
295 }
296}
297
298fn default_true() -> bool {
303 true
304}
305
306fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
307 if env_var.is_empty() {
308 return Err(SecretConfigError::EmptyEnvVar { secret_index });
309 }
310 if env_var.contains('=') {
311 return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
312 }
313 if env_var.contains('\0') {
314 return Err(SecretConfigError::EnvVarContainsNul { secret_index });
315 }
316 Ok(())
317}
318
319fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
320 if placeholder.is_empty() {
321 return Err(SecretConfigError::EmptyPlaceholder { secret_index });
322 }
323
324 let actual_bytes = placeholder.len();
325 if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
326 return Err(SecretConfigError::PlaceholderTooLong {
327 secret_index,
328 actual_bytes,
329 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
330 });
331 }
332
333 if placeholder.contains('\0') {
334 return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
335 }
336 if placeholder.contains('\r') || placeholder.contains('\n') {
337 return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
338 }
339
340 Ok(())
341}
342
343#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn valid_secret() -> SecretEntry {
352 SecretEntry {
353 env_var: "API_KEY".into(),
354 value: "secret".into(),
355 placeholder: "$MSB_API_KEY".into(),
356 allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
357 injection: SecretInjection::default(),
358 on_violation: None,
359 require_tls_identity: true,
360 }
361 }
362
363 #[test]
364 fn exact_host_match() {
365 let p = HostPattern::Exact("api.openai.com".into());
366 assert!(p.matches("api.openai.com"));
367 assert!(p.matches("API.OpenAI.com"));
368 assert!(!p.matches("evil.com"));
369 }
370
371 #[test]
372 fn wildcard_host_match() {
373 let p = HostPattern::Wildcard("*.openai.com".into());
374 assert!(p.matches("api.openai.com"));
375 assert!(p.matches("openai.com"));
376 assert!(!p.matches("evil.com"));
377 }
378
379 #[test]
380 fn any_host_match() {
381 let p = HostPattern::Any;
382 assert!(p.matches("anything.com"));
383 }
384
385 #[test]
386 fn default_injection_scopes() {
387 let inj = SecretInjection::default();
388 assert!(inj.headers);
389 assert!(inj.basic_auth);
390 assert!(!inj.query_params);
391 assert!(!inj.body);
392 }
393
394 #[test]
395 fn default_require_tls_identity() {
396 let entry = SecretEntry {
397 env_var: "K".into(),
398 value: "v".into(),
399 placeholder: "$K".into(),
400 allowed_hosts: vec![],
401 injection: SecretInjection::default(),
402 on_violation: None,
403 require_tls_identity: true,
404 };
405 assert!(entry.require_tls_identity);
406 }
407
408 #[test]
409 fn secret_validation_accepts_linux_environment_name_shape() {
410 let mut entry = valid_secret();
411 entry.env_var = "1TOKEN.with-dashes".into();
412
413 assert!(entry.validate(0).is_ok());
414 }
415
416 #[test]
417 fn secret_validation_rejects_invalid_env_var_names() {
418 let cases = [
419 ("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
420 (
421 "API=KEY",
422 SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
423 ),
424 (
425 "API\0KEY",
426 SecretConfigError::EnvVarContainsNul { secret_index: 0 },
427 ),
428 ];
429
430 for (env_var, expected) in cases {
431 let mut entry = valid_secret();
432 entry.env_var = env_var.into();
433 assert_eq!(entry.validate(0), Err(expected));
434 }
435 }
436
437 #[test]
438 fn secret_validation_rejects_missing_allowed_hosts() {
439 let mut entry = valid_secret();
440 entry.allowed_hosts.clear();
441
442 assert_eq!(
443 entry.validate(0),
444 Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
445 );
446 }
447
448 #[test]
449 fn secret_validation_rejects_invalid_placeholders() {
450 let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
451 let cases = [
452 ("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
453 (
454 too_long.as_str(),
455 SecretConfigError::PlaceholderTooLong {
456 secret_index: 0,
457 actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
458 max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
459 },
460 ),
461 (
462 "abc\0def",
463 SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
464 ),
465 (
466 "abc\rdef",
467 SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
468 ),
469 (
470 "abc\ndef",
471 SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
472 ),
473 ];
474
475 for (placeholder, expected) in cases {
476 let mut entry = valid_secret();
477 entry.placeholder = placeholder.into();
478 assert_eq!(entry.validate(0), Err(expected));
479 }
480 }
481
482 #[test]
483 fn violation_action_serializes_with_sdk_casing() {
484 let action = ViolationAction::Passthrough(vec![
485 HostPattern::Exact("api.anthropic.com".into()),
486 HostPattern::Wildcard("*.anthropic.com".into()),
487 HostPattern::Any,
488 ]);
489
490 assert_eq!(
491 serde_json::to_string(&action).unwrap(),
492 r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
493 );
494 assert_eq!(
495 serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
496 r#""block-and-log""#
497 );
498 assert_eq!(
499 serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
500 r#""block-and-terminate""#
501 );
502 }
503
504 #[test]
505 fn violation_action_accepts_legacy_pascal_case() {
506 let action: ViolationAction =
507 serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
508
509 assert_eq!(
510 action,
511 ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
512 );
513 assert_eq!(
514 serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
515 ViolationAction::BlockAndTerminate
516 );
517 }
518}