use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsConfig {
#[serde(default)]
pub secrets: Vec<SecretEntry>,
#[serde(default)]
pub on_violation: ViolationAction,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretEntry {
pub env_var: String,
pub value: String,
pub placeholder: String,
#[serde(default)]
pub allowed_hosts: Vec<HostPattern>,
#[serde(default)]
pub injection: SecretInjection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_violation: Option<ViolationAction>,
#[serde(default = "default_true")]
pub require_tls_identity: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HostPattern {
#[serde(alias = "Exact")]
Exact(String),
#[serde(alias = "Wildcard")]
Wildcard(String),
#[serde(alias = "Any")]
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretInjection {
#[serde(default = "default_true")]
pub headers: bool,
#[serde(default = "default_true")]
pub basic_auth: bool,
#[serde(default)]
pub query_params: bool,
#[serde(default)]
pub body: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViolationAction {
#[serde(alias = "Block")]
Block,
#[default]
#[serde(alias = "BlockAndLog", alias = "block_and_log")]
BlockAndLog,
#[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
BlockAndTerminate,
#[serde(alias = "Passthrough")]
Passthrough(Vec<HostPattern>),
}
impl std::fmt::Debug for SecretEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretEntry")
.field("env_var", &self.env_var)
.field("value", &"[REDACTED]")
.field("placeholder", &self.placeholder)
.field("allowed_hosts", &self.allowed_hosts)
.field("injection", &self.injection)
.field("on_violation", &self.on_violation)
.field("require_tls_identity", &self.require_tls_identity)
.finish()
}
}
impl HostPattern {
pub fn matches(&self, hostname: &str) -> bool {
match self {
HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
HostPattern::Wildcard(pattern) => {
if let Some(suffix) = pattern.strip_prefix("*.") {
hostname.eq_ignore_ascii_case(suffix)
|| (hostname.len() > suffix.len() + 1
&& hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
&& hostname[hostname.len() - suffix.len()..]
.eq_ignore_ascii_case(suffix))
} else {
hostname.eq_ignore_ascii_case(pattern)
}
}
HostPattern::Any => true,
}
}
}
impl Default for SecretInjection {
fn default() -> Self {
Self {
headers: true,
basic_auth: true,
query_params: false,
body: false,
}
}
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_host_match() {
let p = HostPattern::Exact("api.openai.com".into());
assert!(p.matches("api.openai.com"));
assert!(p.matches("API.OpenAI.com"));
assert!(!p.matches("evil.com"));
}
#[test]
fn wildcard_host_match() {
let p = HostPattern::Wildcard("*.openai.com".into());
assert!(p.matches("api.openai.com"));
assert!(p.matches("openai.com"));
assert!(!p.matches("evil.com"));
}
#[test]
fn any_host_match() {
let p = HostPattern::Any;
assert!(p.matches("anything.com"));
}
#[test]
fn default_injection_scopes() {
let inj = SecretInjection::default();
assert!(inj.headers);
assert!(inj.basic_auth);
assert!(!inj.query_params);
assert!(!inj.body);
}
#[test]
fn default_require_tls_identity() {
let entry = SecretEntry {
env_var: "K".into(),
value: "v".into(),
placeholder: "$K".into(),
allowed_hosts: vec![],
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
};
assert!(entry.require_tls_identity);
}
#[test]
fn violation_action_serializes_with_sdk_casing() {
let action = ViolationAction::Passthrough(vec![
HostPattern::Exact("api.anthropic.com".into()),
HostPattern::Wildcard("*.anthropic.com".into()),
HostPattern::Any,
]);
assert_eq!(
serde_json::to_string(&action).unwrap(),
r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
r#""block-and-log""#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
r#""block-and-terminate""#
);
}
#[test]
fn violation_action_accepts_legacy_pascal_case() {
let action: ViolationAction =
serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
assert_eq!(
action,
ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
);
assert_eq!(
serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
ViolationAction::BlockAndTerminate
);
}
}