use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use hush_proxy::policy::{DomainPolicy, PolicyAction};
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EgressAllowlistConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub block: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_action: Option<PolicyAction>,
#[serde(default)]
pub additional_allow: Vec<String>,
#[serde(default)]
pub remove_allow: Vec<String>,
#[serde(default)]
pub additional_block: Vec<String>,
#[serde(default)]
pub remove_block: Vec<String>,
}
impl Default for EgressAllowlistConfig {
fn default() -> Self {
Self::with_defaults()
}
}
fn default_enabled() -> bool {
true
}
impl EgressAllowlistConfig {
pub fn with_defaults() -> Self {
Self {
enabled: true,
allow: vec![
"*.openai.com".to_string(),
"*.anthropic.com".to_string(),
"api.github.com".to_string(),
"*.npmjs.org".to_string(),
"registry.npmjs.org".to_string(),
"pypi.org".to_string(),
"files.pythonhosted.org".to_string(),
"crates.io".to_string(),
"static.crates.io".to_string(),
],
block: vec![],
default_action: Some(PolicyAction::Block),
additional_allow: vec![],
remove_allow: vec![],
additional_block: vec![],
remove_block: vec![],
}
}
pub fn merge_with(&self, child: &Self) -> Self {
let mut allow = self.allow.clone();
let mut block = self.block.clone();
for d in &child.additional_allow {
if !allow.contains(d) {
allow.push(d.clone());
}
}
for d in &child.additional_block {
if !block.contains(d) {
block.push(d.clone());
}
}
allow.retain(|d| !child.remove_allow.contains(d));
block.retain(|d| !child.remove_block.contains(d));
if !child.allow.is_empty() {
allow = child.allow.clone();
}
if !child.block.is_empty() {
block = child.block.clone();
}
Self {
enabled: child.enabled,
allow,
block,
default_action: child
.default_action
.clone()
.or_else(|| self.default_action.clone()),
additional_allow: vec![],
remove_allow: vec![],
additional_block: vec![],
remove_block: vec![],
}
}
}
pub struct EgressAllowlistGuard {
name: String,
enabled: bool,
policy: DomainPolicy,
}
impl EgressAllowlistGuard {
pub fn new() -> Self {
Self::with_config(EgressAllowlistConfig::with_defaults())
}
pub fn with_config(config: EgressAllowlistConfig) -> Self {
let enabled = config.enabled;
let mut policy = DomainPolicy::new();
policy.set_default_action(config.default_action.unwrap_or_default());
policy.extend_allow(config.allow);
policy.extend_block(config.block);
Self {
name: "egress_allowlist".to_string(),
enabled,
policy,
}
}
pub fn is_allowed(&self, domain: &str) -> bool {
self.policy.is_allowed(domain)
}
}
impl Default for EgressAllowlistGuard {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Guard for EgressAllowlistGuard {
fn name(&self) -> &str {
&self.name
}
fn handles(&self, action: &GuardAction<'_>) -> bool {
if !self.enabled {
return false;
}
matches!(action, GuardAction::NetworkEgress(_, _))
}
async fn check(&self, action: &GuardAction<'_>, _context: &GuardContext) -> GuardResult {
if !self.enabled {
return GuardResult::allow(&self.name);
}
let (host, port) = match action {
GuardAction::NetworkEgress(h, p) => (*h, *p),
_ => return GuardResult::allow(&self.name),
};
let result = self.policy.evaluate_detailed(host);
match result.action {
PolicyAction::Allow => GuardResult::allow(&self.name),
PolicyAction::Block => GuardResult::block(
&self.name,
Severity::Error,
format!("Egress to {} blocked by policy", host),
)
.with_details(serde_json::json!({
"host": host,
"port": port,
"matched_pattern": result.matched_pattern,
"is_default": result.is_default,
})),
PolicyAction::Log => {
GuardResult::warn(&self.name, format!("Egress to {} logged", host)).with_details(
serde_json::json!({
"host": host,
"port": port,
}),
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_allowlist() {
let guard = EgressAllowlistGuard::new();
assert!(guard.is_allowed("api.openai.com"));
assert!(guard.is_allowed("api.anthropic.com"));
assert!(guard.is_allowed("registry.npmjs.org"));
assert!(!guard.is_allowed("evil.com"));
assert!(!guard.is_allowed("random-site.org"));
}
#[test]
fn test_custom_config() {
let config = EgressAllowlistConfig {
allow: vec!["*.mycompany.com".to_string()],
block: vec!["blocked.mycompany.com".to_string()],
default_action: Some(PolicyAction::Block),
..Default::default()
};
let guard = EgressAllowlistGuard::with_config(config);
assert!(guard.is_allowed("api.mycompany.com"));
assert!(!guard.is_allowed("blocked.mycompany.com")); assert!(!guard.is_allowed("other.com"));
}
#[tokio::test]
async fn test_guard_check() {
let guard = EgressAllowlistGuard::new();
let context = GuardContext::new();
let result = guard
.check(&GuardAction::NetworkEgress("api.openai.com", 443), &context)
.await;
assert!(result.allowed);
let result = guard
.check(&GuardAction::NetworkEgress("evil.com", 443), &context)
.await;
assert!(!result.allowed);
assert_eq!(result.severity, Severity::Error);
}
}