feat(network-proxy): add embedded OTEL policy audit logging (#12046)
**PR Summary** This PR adds embedded-only OTEL policy audit logging for `codex-network-proxy` and threads audit metadata from `codex-core` into managed proxy startup. ### What changed - Added structured audit event emission in `network_policy.rs` with target `codex_otel.network_proxy`. - Emitted: - `codex.network_proxy.domain_policy_decision` once per domain-policy evaluation. - `codex.network_proxy.block_decision` for non-domain denies. - Added required policy/network fields, RFC3339 UTC millisecond `event.timestamp`, and fallback defaults (`http.request.method="none"`, `client.address="unknown"`). - Added non-domain deny audit emission in HTTP/SOCKS handlers for mode-guard and proxy-state denies, including unix-socket deny paths. - Added `REASON_UNIX_SOCKET_UNSUPPORTED` and used it for unsupported unix-socket auditing. - Added `NetworkProxyAuditMetadata` to runtime/state, re-exported from `lib.rs` and `state.rs`. - Added `start_proxy_with_audit_metadata(...)` in core config, with `start_proxy()` delegating to default metadata. - Wired metadata construction in `codex.rs` from session/auth context, including originator sanitization for OTEL-safe tagging. - Updated `network-proxy/README.md` with embedded-mode audit schema and behavior notes. - Refactored HTTP block-audit emission to a small local helper to reduce duplication. - Preserved existing unix-socket proxy-disabled host/path behavior for responses and blocked history while using an audit-only endpoint override (`server.address="unix-socket"`, `server.port=0`). ### Explicit exclusions - No standalone proxy OTEL startup work. - No `main.rs` binary wiring. - No `standalone_otel.rs`. - No standalone docs/tests. ### Tests - Extended `network_policy.rs` tests for event mapping, metadata propagation, fallbacks, timestamp format, and target prefix. - Extended HTTP tests to assert unix-socket deny block audit events. - Extended SOCKS tests to cover deny emission from handler deny branches. - Added/updated core tests to verify audit metadata threading into managed proxy state. ### Validation run - `just fmt` - `cargo test -p codex-network-proxy` ✅ - `cargo test -p codex-core` ran with one unrelated flaky timeout (`shell_snapshot::tests::snapshot_shell_does_not_inherit_stdin`), and the test passed when rerun directly ✅ --------- Co-authored-by: viyatb-oai <viyatb@openai.com>
This commit is contained in:
parent
8362b79cb4
commit
9a393c9b6f
16 changed files with 1592 additions and 657 deletions
230
MODULE.bazel.lock
generated
230
MODULE.bazel.lock
generated
File diff suppressed because one or more lines are too long
834
codex-rs/Cargo.lock
generated
834
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -190,6 +190,7 @@ use codex_core::auth::login_with_chatgpt_auth_tokens;
|
|||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigService;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
|
|
@ -1751,6 +1752,7 @@ impl CodexMessageProcessor {
|
|||
None,
|
||||
None,
|
||||
managed_network_requirements_enabled,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::path::PathBuf;
|
|||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::landlock::spawn_command_under_linux_sandbox;
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -223,6 +224,7 @@ async fn run_command_under_sandbox(
|
|||
None,
|
||||
None,
|
||||
managed_network_requirements_enabled,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ use codex_hooks::HookResult;
|
|||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
|
|
@ -877,6 +878,7 @@ impl Session {
|
|||
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
|
||||
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
|
||||
managed_network_requirements_enabled: bool,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
|
||||
let network_proxy = spec
|
||||
.start_proxy(
|
||||
|
|
@ -884,6 +886,7 @@ impl Session {
|
|||
network_policy_decider,
|
||||
blocked_request_observer,
|
||||
managed_network_requirements_enabled,
|
||||
audit_metadata,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?;
|
||||
|
|
@ -1198,21 +1201,37 @@ impl Session {
|
|||
|
||||
let auth = auth.as_ref();
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
let account_id = auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = auth.and_then(CodexAuth::get_account_email);
|
||||
let originator = crate::default_client::originator().value;
|
||||
let terminal_type = terminal::user_agent();
|
||||
let session_model = session_configuration.collaboration_mode.model().to_string();
|
||||
let mut otel_manager = OtelManager::new(
|
||||
conversation_id,
|
||||
session_configuration.collaboration_mode.model(),
|
||||
session_configuration.collaboration_mode.model(),
|
||||
auth.and_then(CodexAuth::get_account_id),
|
||||
auth.and_then(CodexAuth::get_account_email),
|
||||
session_model.as_str(),
|
||||
session_model.as_str(),
|
||||
account_id.clone(),
|
||||
account_email.clone(),
|
||||
auth_mode,
|
||||
crate::default_client::originator().value,
|
||||
originator.clone(),
|
||||
config.otel.log_user_prompt,
|
||||
terminal::user_agent(),
|
||||
terminal_type.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
);
|
||||
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
|
||||
otel_manager = otel_manager.with_metrics_service_name(service_name);
|
||||
}
|
||||
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some(conversation_id.to_string()),
|
||||
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
user_account_id: account_id,
|
||||
auth_mode: auth_mode.map(|mode| mode.to_string()),
|
||||
originator: Some(originator),
|
||||
user_email: account_email,
|
||||
terminal_type: Some(terminal_type),
|
||||
model: Some(session_model.clone()),
|
||||
slug: Some(session_model),
|
||||
};
|
||||
config.features.emit_metrics(&otel_manager);
|
||||
otel_manager.counter(
|
||||
"codex.thread.started",
|
||||
|
|
@ -1319,6 +1338,7 @@ impl Session {
|
|||
network_policy_decider.as_ref().map(Arc::clone),
|
||||
blocked_request_observer.as_ref().map(Arc::clone),
|
||||
managed_network_requirements_enabled,
|
||||
network_proxy_audit_metadata,
|
||||
)
|
||||
.await?;
|
||||
(Some(network_proxy), Some(session_network_proxy))
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ pub mod types;
|
|||
pub use codex_config::Constrained;
|
||||
pub use codex_config::ConstraintError;
|
||||
pub use codex_config::ConstraintResult;
|
||||
pub use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
|
||||
pub use network_proxy_spec::NetworkProxySpec;
|
||||
pub use network_proxy_spec::StartedNetworkProxy;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use codex_network_proxy::ConfigState;
|
|||
use codex_network_proxy::NetworkDecision;
|
||||
use codex_network_proxy::NetworkPolicyDecider;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyHandle;
|
||||
|
|
@ -106,13 +107,9 @@ impl NetworkProxySpec {
|
|||
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
|
||||
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
||||
enable_network_approval_flow: bool,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> std::io::Result<StartedNetworkProxy> {
|
||||
let state =
|
||||
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy state: {err}"))
|
||||
})?;
|
||||
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
|
||||
let state = NetworkProxyState::with_reloader(state, reloader);
|
||||
let state = self.build_state_with_audit_metadata(audit_metadata)?;
|
||||
let mut builder = NetworkProxy::builder().state(Arc::new(state));
|
||||
if enable_network_approval_flow
|
||||
&& matches!(
|
||||
|
|
@ -142,6 +139,22 @@ impl NetworkProxySpec {
|
|||
Ok(StartedNetworkProxy::new(proxy, handle))
|
||||
}
|
||||
|
||||
fn build_state_with_audit_metadata(
|
||||
&self,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> std::io::Result<NetworkProxyState> {
|
||||
let state =
|
||||
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
|
||||
std::io::Error::other(format!("failed to build network proxy state: {err}"))
|
||||
})?;
|
||||
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
|
||||
Ok(NetworkProxyState::with_reloader_and_audit_metadata(
|
||||
state,
|
||||
reloader,
|
||||
audit_metadata,
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_requirements(
|
||||
mut config: NetworkProxyConfig,
|
||||
requirements: &NetworkConstraints,
|
||||
|
|
@ -205,3 +218,28 @@ impl NetworkProxySpec {
|
|||
(config, constraints)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn build_state_with_audit_metadata_threads_metadata_to_state() {
|
||||
let spec = NetworkProxySpec {
|
||||
config: NetworkProxyConfig::default(),
|
||||
constraints: NetworkProxyConstraints::default(),
|
||||
};
|
||||
let metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
app_version: Some("1.2.3".to_string()),
|
||||
user_account_id: Some("acct-1".to_string()),
|
||||
..NetworkProxyAuditMetadata::default()
|
||||
};
|
||||
|
||||
let state = spec
|
||||
.build_state_with_audit_metadata(metadata.clone())
|
||||
.expect("state should build");
|
||||
assert_eq!(state.audit_metadata(), &metadata);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ workspace = true
|
|||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
chrono = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -137,6 +137,45 @@ the decider can auto-allow network requests originating from that command.
|
|||
**Important:** Explicit deny rules still win. The decider only gets a chance to override
|
||||
`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`.
|
||||
|
||||
## OTEL Audit Events (embedded/managed)
|
||||
|
||||
When `codex-network-proxy` is embedded in managed Codex runtime, policy decisions emit structured
|
||||
OTEL-compatible events with `target=codex_otel.network_proxy`.
|
||||
|
||||
Event name:
|
||||
|
||||
- `codex.network_proxy.policy_decision`
|
||||
- emitted for each policy decision (`domain` and `non_domain`).
|
||||
- `network.policy.scope = "domain"` for host-policy evaluations (`evaluate_host_policy`).
|
||||
- `network.policy.scope = "non_domain"` for mode-guard/proxy-state checks (including unix-socket guard paths and unix-socket allow decisions).
|
||||
|
||||
Common fields:
|
||||
|
||||
- `event.name`
|
||||
- `event.timestamp` (RFC3339 UTC, millisecond precision)
|
||||
- optional metadata:
|
||||
- `conversation.id`
|
||||
- `app.version`
|
||||
- `user.account_id`
|
||||
- policy/network:
|
||||
- `network.policy.scope` (`domain` or `non_domain`)
|
||||
- `network.policy.decision` (`allow`, `deny`, or `ask`)
|
||||
- `network.policy.source` (`baseline_policy`, `mode_guard`, `proxy_state`, `decider`)
|
||||
- `network.policy.reason`
|
||||
- `network.transport.protocol`
|
||||
- `server.address`
|
||||
- `server.port`
|
||||
- `http.request.method` (defaults to `"none"` when absent)
|
||||
- `client.address` (defaults to `"unknown"` when absent)
|
||||
- `network.policy.override` (`true` only when decider-allow overrides baseline `not_allowed`)
|
||||
|
||||
Unix-socket block-path audits use sentinel endpoint values:
|
||||
|
||||
- `server.address = "unix-socket"`
|
||||
- `server.port = 0`
|
||||
|
||||
Audit events intentionally avoid logging full URL/path/query data.
|
||||
|
||||
## Admin API
|
||||
|
||||
The admin API is a small HTTP server intended for debugging and runtime adjustments.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::config::NetworkMode;
|
||||
use crate::mitm;
|
||||
use crate::network_policy::BlockDecisionAuditEventArgs;
|
||||
use crate::network_policy::NetworkDecision;
|
||||
use crate::network_policy::NetworkDecisionSource;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
|
|
@ -7,12 +8,15 @@ use crate::network_policy::NetworkPolicyDecision;
|
|||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::emit_allow_decision_audit_event;
|
||||
use crate::network_policy::emit_block_decision_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_REQUIRED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_PROXY_DISABLED;
|
||||
use crate::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
|
||||
use crate::responses::PolicyDecisionDetails;
|
||||
use crate::responses::blocked_header_value;
|
||||
use crate::responses::blocked_message_with_policy;
|
||||
|
|
@ -176,6 +180,7 @@ async fn http_connect_accept(
|
|||
client_addr(&req),
|
||||
Some("CONNECT".to_string()),
|
||||
NetworkProtocol::HttpsConnect,
|
||||
None,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
|
@ -247,6 +252,18 @@ async fn http_connect_accept(
|
|||
if mode == NetworkMode::Limited && mitm_state.is_none() {
|
||||
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
|
||||
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_MITM_REQUIRED,
|
||||
protocol: NetworkProtocol::HttpsConnect,
|
||||
server_address: host.as_str(),
|
||||
server_port: authority.port,
|
||||
method: Some("CONNECT"),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_MITM_REQUIRED,
|
||||
|
|
@ -449,10 +466,23 @@ async fn http_plain_proxy(
|
|||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
NetworkProtocol::Http,
|
||||
Some(("unix-socket", 0)),
|
||||
)
|
||||
.await);
|
||||
}
|
||||
if !method_allowed {
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
let method = req.method();
|
||||
warn!(
|
||||
|
|
@ -462,6 +492,18 @@ async fn http_plain_proxy(
|
|||
}
|
||||
|
||||
if !unix_socket_permissions_supported() {
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_UNIX_SOCKET_UNSUPPORTED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
warn!("unix socket proxy unsupported on this platform (path={socket_path})");
|
||||
return Ok(text_response(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
|
|
@ -471,6 +513,18 @@ async fn http_plain_proxy(
|
|||
|
||||
return match app_state.is_unix_socket_allowed(&socket_path).await {
|
||||
Ok(true) => {
|
||||
emit_http_allow_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: "allow",
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
info!("unix socket allowed (client={client}, path={socket_path})");
|
||||
match proxy_via_unix_socket(req, &socket_path).await {
|
||||
|
|
@ -485,6 +539,18 @@ async fn http_plain_proxy(
|
|||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
warn!("unix socket blocked (client={client}, path={socket_path})");
|
||||
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED, None))
|
||||
|
|
@ -528,6 +594,7 @@ async fn http_plain_proxy(
|
|||
client_addr(&req),
|
||||
Some(req.method().as_str().to_string()),
|
||||
NetworkProtocol::Http,
|
||||
None,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
|
@ -581,6 +648,18 @@ async fn http_plain_proxy(
|
|||
}
|
||||
|
||||
if !method_allowed {
|
||||
emit_http_block_decision_audit_event(
|
||||
&app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: host.as_str(),
|
||||
server_port: port,
|
||||
method: Some(req.method().as_str()),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
|
|
@ -794,7 +873,23 @@ async fn proxy_disabled_response(
|
|||
client: Option<String>,
|
||||
method: Option<String>,
|
||||
protocol: NetworkProtocol,
|
||||
audit_endpoint_override: Option<(&str, u16)>,
|
||||
) -> Response {
|
||||
let (audit_server_address, audit_server_port) =
|
||||
audit_endpoint_override.unwrap_or((host.as_str(), port));
|
||||
emit_http_block_decision_audit_event(
|
||||
app_state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ProxyState,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
protocol,
|
||||
server_address: audit_server_address,
|
||||
server_port: audit_server_port,
|
||||
method: method.as_deref(),
|
||||
client_addr: client.as_deref(),
|
||||
},
|
||||
);
|
||||
|
||||
let blocked_host = host.clone();
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
|
|
@ -837,6 +932,20 @@ fn text_response(status: StatusCode, body: &str) -> Response {
|
|||
.unwrap_or_else(|_| Response::new(Body::from(body.to_string())))
|
||||
}
|
||||
|
||||
fn emit_http_block_decision_audit_event(
|
||||
app_state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_block_decision_audit_event(app_state, args);
|
||||
}
|
||||
|
||||
fn emit_http_allow_decision_audit_event(
|
||||
app_state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_allow_decision_audit_event(app_state, args);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BlockedResponse<'a> {
|
||||
status: &'static str,
|
||||
|
|
@ -911,6 +1020,80 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
|
||||
let state = Arc::new(network_proxy_state_for_policy(
|
||||
NetworkProxySettings::default(),
|
||||
));
|
||||
state
|
||||
.set_network_mode(NetworkMode::Limited)
|
||||
.await
|
||||
.expect("network mode should update");
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("http://example.com")
|
||||
.header("x-unix-socket", "/tmp/test.sock")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let response = http_plain_proxy(None, req).await.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-method-policy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_rejects_unix_socket_when_not_allowlisted() {
|
||||
let state = Arc::new(network_proxy_state_for_policy(
|
||||
NetworkProxySettings::default(),
|
||||
));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com")
|
||||
.header("x-unix-socket", "/tmp/test.sock")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let response = http_plain_proxy(None, req).await.unwrap();
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
response.headers().get("x-proxy-error").unwrap(),
|
||||
"blocked-by-allowlist"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() {
|
||||
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allow_unix_sockets: vec!["/tmp/test.sock".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
}));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("http://example.com")
|
||||
.header("x-unix-socket", "/tmp/test.sock")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
req.extensions_mut().insert(state);
|
||||
|
||||
let response = http_plain_proxy(None, req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_denies_denylisted_host() {
|
||||
let policy = NetworkProxySettings {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub use runtime::BlockedRequestObserver;
|
|||
pub use runtime::ConfigReloader;
|
||||
pub use runtime::ConfigState;
|
||||
pub use runtime::NetworkProxyState;
|
||||
pub use state::NetworkProxyAuditMetadata;
|
||||
pub use state::NetworkProxyConstraintError;
|
||||
pub use state::NetworkProxyConstraints;
|
||||
pub use state::PartialNetworkConfig;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,21 @@ use crate::runtime::HostBlockReason;
|
|||
use crate::state::NetworkProxyState;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
const AUDIT_TARGET: &str = "codex_otel.network_proxy";
|
||||
const POLICY_DECISION_EVENT_NAME: &str = "codex.network_proxy.policy_decision";
|
||||
const POLICY_SCOPE_DOMAIN: &str = "domain";
|
||||
const POLICY_SCOPE_NON_DOMAIN: &str = "non_domain";
|
||||
const POLICY_DECISION_ALLOW: &str = "allow";
|
||||
const POLICY_DECISION_DENY: &str = "deny";
|
||||
const POLICY_REASON_ALLOW: &str = "allow";
|
||||
const DEFAULT_METHOD: &str = "none";
|
||||
const DEFAULT_CLIENT_ADDRESS: &str = "unknown";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkProtocol {
|
||||
Http,
|
||||
|
|
@ -154,6 +166,98 @@ impl NetworkDecision {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BlockDecisionAuditEventArgs<'a> {
|
||||
pub source: NetworkDecisionSource,
|
||||
pub reason: &'a str,
|
||||
pub protocol: NetworkProtocol,
|
||||
pub server_address: &'a str,
|
||||
pub server_port: u16,
|
||||
pub method: Option<&'a str>,
|
||||
pub client_addr: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_block_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_DENY);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_allow_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_ALLOW);
|
||||
}
|
||||
|
||||
fn emit_non_domain_policy_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
decision: &'static str,
|
||||
) {
|
||||
emit_policy_audit_event(
|
||||
state,
|
||||
PolicyAuditEventArgs {
|
||||
scope: POLICY_SCOPE_NON_DOMAIN,
|
||||
decision,
|
||||
source: args.source.as_str(),
|
||||
reason: args.reason,
|
||||
protocol: args.protocol,
|
||||
server_address: args.server_address,
|
||||
server_port: args.server_port,
|
||||
method: args.method,
|
||||
client_addr: args.client_addr,
|
||||
policy_override: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct PolicyAuditEventArgs<'a> {
|
||||
scope: &'static str,
|
||||
decision: &'a str,
|
||||
source: &'a str,
|
||||
reason: &'a str,
|
||||
protocol: NetworkProtocol,
|
||||
server_address: &'a str,
|
||||
server_port: u16,
|
||||
method: Option<&'a str>,
|
||||
client_addr: Option<&'a str>,
|
||||
policy_override: bool,
|
||||
}
|
||||
|
||||
fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
|
||||
let metadata = state.audit_metadata();
|
||||
tracing::event!(
|
||||
target: AUDIT_TARGET,
|
||||
tracing::Level::INFO,
|
||||
event.name = POLICY_DECISION_EVENT_NAME,
|
||||
event.timestamp = %audit_timestamp(),
|
||||
conversation.id = metadata.conversation_id.as_deref(),
|
||||
app.version = metadata.app_version.as_deref(),
|
||||
auth_mode = metadata.auth_mode.as_deref(),
|
||||
originator = metadata.originator.as_deref(),
|
||||
user.account_id = metadata.user_account_id.as_deref(),
|
||||
user.email = metadata.user_email.as_deref(),
|
||||
terminal.type = metadata.terminal_type.as_deref(),
|
||||
model = metadata.model.as_deref(),
|
||||
slug = metadata.slug.as_deref(),
|
||||
network.policy.scope = args.scope,
|
||||
network.policy.decision = args.decision,
|
||||
network.policy.source = args.source,
|
||||
network.policy.reason = args.reason,
|
||||
network.transport.protocol = args.protocol.as_policy_protocol(),
|
||||
server.address = args.server_address,
|
||||
server.port = args.server_port,
|
||||
http.request.method = args.method.unwrap_or(DEFAULT_METHOD),
|
||||
client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS),
|
||||
network.policy.override = args.policy_override,
|
||||
);
|
||||
}
|
||||
|
||||
fn audit_timestamp() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
/// Decide whether a network request should be allowed.
|
||||
///
|
||||
/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy
|
||||
|
|
@ -187,23 +291,71 @@ pub(crate) async fn evaluate_host_policy(
|
|||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Result<NetworkDecision> {
|
||||
match state.host_blocked(&request.host, request.port).await? {
|
||||
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
|
||||
let host_decision = state.host_blocked(&request.host, request.port).await?;
|
||||
let (decision, policy_override) = match host_decision {
|
||||
HostBlockDecision::Allowed => (NetworkDecision::Allow, false),
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
|
||||
if let Some(decider) = decider {
|
||||
Ok(map_decider_decision(decider.decide(request.clone()).await))
|
||||
let decider_decision = map_decider_decision(decider.decide(request.clone()).await);
|
||||
let policy_override = matches!(decider_decision, NetworkDecision::Allow);
|
||||
(decider_decision, policy_override)
|
||||
} else {
|
||||
Ok(NetworkDecision::deny_with_source(
|
||||
HostBlockReason::NotAllowed.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
))
|
||||
(
|
||||
NetworkDecision::deny_with_source(
|
||||
HostBlockReason::NotAllowed.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny_with_source(
|
||||
reason.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
)),
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => (
|
||||
NetworkDecision::deny_with_source(
|
||||
reason.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
let (policy_decision, source, reason) = match &decision {
|
||||
NetworkDecision::Allow => (
|
||||
POLICY_DECISION_ALLOW,
|
||||
if policy_override {
|
||||
NetworkDecisionSource::Decider
|
||||
} else {
|
||||
NetworkDecisionSource::BaselinePolicy
|
||||
},
|
||||
if policy_override {
|
||||
HostBlockReason::NotAllowed.as_str()
|
||||
} else {
|
||||
POLICY_REASON_ALLOW
|
||||
},
|
||||
),
|
||||
NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
decision,
|
||||
} => (decision.as_str(), *source, reason.as_str()),
|
||||
};
|
||||
|
||||
emit_policy_audit_event(
|
||||
state,
|
||||
PolicyAuditEventArgs {
|
||||
scope: POLICY_SCOPE_DOMAIN,
|
||||
decision: policy_decision,
|
||||
source: source.as_str(),
|
||||
reason,
|
||||
protocol: request.protocol,
|
||||
server_address: request.host.as_str(),
|
||||
server_port: request.port,
|
||||
method: request.method.as_deref(),
|
||||
client_addr: request.client_addr.as_deref(),
|
||||
policy_override,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
|
|
@ -219,21 +371,244 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
pub(crate) const POLICY_DECISION_EVENT_NAME: &str = super::POLICY_DECISION_EVENT_NAME;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::Event;
|
||||
use tracing::Id;
|
||||
use tracing::Metadata;
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::Field;
|
||||
use tracing::field::Visit;
|
||||
use tracing::span::Attributes;
|
||||
use tracing::span::Record;
|
||||
use tracing::subscriber::Interest;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CapturedEvent {
|
||||
pub target: String,
|
||||
pub fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl CapturedEvent {
|
||||
pub fn field(&self, name: &str) -> Option<&str> {
|
||||
self.fields.get(name).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct EventCollector {
|
||||
events: Arc<Mutex<Vec<CapturedEvent>>>,
|
||||
next_span_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl EventCollector {
|
||||
fn events(&self) -> Vec<CapturedEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscriber for EventCollector {
|
||||
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn register_callsite(&self, _metadata: &'static Metadata<'static>) -> Interest {
|
||||
Interest::always()
|
||||
}
|
||||
|
||||
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
|
||||
Some(tracing::level_filters::LevelFilter::TRACE)
|
||||
}
|
||||
|
||||
fn new_span(&self, _span: &Attributes<'_>) -> Id {
|
||||
Id::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed) + 1)
|
||||
}
|
||||
|
||||
fn record(&self, _span: &Id, _values: &Record<'_>) {}
|
||||
|
||||
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
|
||||
|
||||
fn event(&self, event: &Event<'_>) {
|
||||
let mut visitor = FieldVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(CapturedEvent {
|
||||
target: event.metadata().target().to_string(),
|
||||
fields: visitor.fields,
|
||||
});
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &Id) {}
|
||||
|
||||
fn exit(&self, _span: &Id) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldVisitor {
|
||||
fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FieldVisitor {
|
||||
fn insert(&mut self, field: &Field, value: impl Into<String>) {
|
||||
self.fields.insert(field.name().to_string(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for FieldVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.insert(field, value);
|
||||
}
|
||||
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_i128(&mut self, field: &Field, value: i128) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_u128(&mut self, field: &Field, value: u128) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_f64(&mut self, field: &Field, value: f64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
||||
self.insert(field, format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn capture_events<F, Fut, T>(f: F) -> (T, Vec<CapturedEvent>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let collector = EventCollector::default();
|
||||
let _guard = tracing::subscriber::set_default(collector.clone());
|
||||
let output = f().await;
|
||||
let events = collector.events();
|
||||
(output, events)
|
||||
}
|
||||
|
||||
pub(crate) fn find_event_by_name<'a>(
|
||||
events: &'a [CapturedEvent],
|
||||
event_name: &str,
|
||||
) -> Option<&'a CapturedEvent> {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event.field("event.name") == Some(event_name))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::capture_events;
|
||||
use super::test_support::find_event_by_name;
|
||||
use super::*;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::runtime::ConfigReloader;
|
||||
use crate::runtime::ConfigState;
|
||||
use crate::runtime::NetworkProxyAuditMetadata;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
use crate::state::build_config_state;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_invokes_decider_for_not_allowed() {
|
||||
const LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME: &str =
|
||||
"codex.network_proxy.domain_policy_decision";
|
||||
const LEGACY_BLOCK_DECISION_EVENT_NAME: &str = "codex.network_proxy.block_decision";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StaticReloader {
|
||||
state: ConfigState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigReloader for StaticReloader {
|
||||
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
|
||||
Ok(self.state.clone())
|
||||
}
|
||||
|
||||
fn source_label(&self) -> String {
|
||||
"static test reloader".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with_metadata(metadata: NetworkProxyAuditMetadata) -> NetworkProxyState {
|
||||
let network = NetworkProxySettings {
|
||||
enabled: true,
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
};
|
||||
let config = NetworkProxyConfig { network };
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
|
||||
let reloader = Arc::new(StaticReloader {
|
||||
state: state.clone(),
|
||||
});
|
||||
NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, metadata)
|
||||
}
|
||||
|
||||
fn is_rfc3339_utc_millis(timestamp: &str) -> bool {
|
||||
let bytes = timestamp.as_bytes();
|
||||
if bytes.len() != 24 {
|
||||
return false;
|
||||
}
|
||||
bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes[10] == b'T'
|
||||
&& bytes[13] == b':'
|
||||
&& bytes[16] == b':'
|
||||
&& bytes[19] == b'.'
|
||||
&& bytes[23] == b'Z'
|
||||
&& bytes.iter().enumerate().all(|(idx, value)| match idx {
|
||||
4 | 7 | 10 | 13 | 16 | 19 | 23 => true,
|
||||
_ => value.is_ascii_digit(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_decider_allow_override() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
|
|
@ -251,47 +626,75 @@ mod tests {
|
|||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
method: None,
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(decision, NetworkDecision::Allow);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.target, AUDIT_TARGET);
|
||||
assert!(event.target.starts_with("codex_otel."));
|
||||
assert_eq!(
|
||||
event.field("network.policy.scope"),
|
||||
Some(POLICY_SCOPE_DOMAIN)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.decision"), Some("allow"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("decider"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.transport.protocol"), Some("http"));
|
||||
assert_eq!(event.field("server.address"), Some("example.com"));
|
||||
assert_eq!(event.field("server.port"), Some("80"));
|
||||
assert_eq!(event.field("http.request.method"), Some(DEFAULT_METHOD));
|
||||
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
|
||||
assert_eq!(event.field("network.policy.override"), Some("true"));
|
||||
let timestamp = event
|
||||
.field("event.timestamp")
|
||||
.expect("event timestamp should be present");
|
||||
assert!(is_rfc3339_utc_millis(timestamp));
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_skips_decider_for_denied() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
denied_domains: vec!["blocked.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
let calls = calls.clone();
|
||||
move |_req| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { NetworkDecision::Allow }
|
||||
}
|
||||
});
|
||||
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "blocked.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
client_addr: Some("127.0.0.1:1234".to_string()),
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, None, &request).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
|
|
@ -300,25 +703,158 @@ mod tests {
|
|||
decision: NetworkPolicyDecision::Deny,
|
||||
}
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("network.policy.decision"), Some("deny"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.source"),
|
||||
Some("baseline_policy")
|
||||
);
|
||||
assert_eq!(event.field("network.policy.reason"), Some(REASON_DENIED));
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
assert_eq!(event.field("http.request.method"), Some("GET"));
|
||||
assert_eq!(event.field("client.address"), Some("127.0.0.1:1234"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_skips_decider_for_not_allowed_local() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_decider_ask() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
let decider: Arc<dyn NetworkPolicyDecider> =
|
||||
Arc::new(|_req| async { NetworkDecision::ask(REASON_NOT_ALLOWED) });
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
reason: REASON_NOT_ALLOWED.to_string(),
|
||||
source: NetworkDecisionSource::Decider,
|
||||
decision: NetworkPolicyDecision::Ask,
|
||||
}
|
||||
);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("network.policy.decision"), Some("ask"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("decider"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_metadata_fields() {
|
||||
let metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
app_version: Some("1.2.3".to_string()),
|
||||
user_account_id: Some("acct-1".to_string()),
|
||||
auth_mode: Some("Chatgpt".to_string()),
|
||||
originator: Some("codex_cli_rs".to_string()),
|
||||
user_email: Some("test@example.com".to_string()),
|
||||
terminal_type: Some("iTerm.app/3.6.5".to_string()),
|
||||
model: Some("gpt-5.3-codex".to_string()),
|
||||
slug: Some("gpt-5.3-codex".to_string()),
|
||||
};
|
||||
let state = state_with_metadata(metadata);
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let (_decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, None, &request).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("conversation.id"), Some("conversation-1"));
|
||||
assert_eq!(event.field("app.version"), Some("1.2.3"));
|
||||
assert_eq!(event.field("auth_mode"), Some("Chatgpt"));
|
||||
assert_eq!(event.field("originator"), Some("codex_cli_rs"));
|
||||
assert_eq!(event.field("user.account_id"), Some("acct-1"));
|
||||
assert_eq!(event.field("user.email"), Some("test@example.com"));
|
||||
assert_eq!(event.field("terminal.type"), Some("iTerm.app/3.6.5"));
|
||||
assert_eq!(event.field("model"), Some("gpt-5.3-codex"));
|
||||
assert_eq!(event.field("slug"), Some("gpt-5.3-codex"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn emit_block_decision_audit_event_emits_non_domain_event() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
||||
let (_, events) = capture_events(|| async {
|
||||
emit_block_decision_audit_event(
|
||||
&state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some("POST"),
|
||||
client_addr: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.target, AUDIT_TARGET);
|
||||
assert_eq!(
|
||||
event.field("network.policy.scope"),
|
||||
Some(POLICY_SCOPE_NON_DOMAIN)
|
||||
);
|
||||
assert_eq!(
|
||||
event.field("network.policy.decision"),
|
||||
Some(POLICY_DECISION_DENY)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_METHOD_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.transport.protocol"), Some("http"));
|
||||
assert_eq!(event.field("server.address"), Some("unix-socket"));
|
||||
assert_eq!(event.field("server.port"), Some("0"));
|
||||
assert_eq!(event.field("http.request.method"), Some("POST"));
|
||||
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
let calls = calls.clone();
|
||||
move |_req| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { NetworkDecision::Allow }
|
||||
}
|
||||
});
|
||||
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "127.0.0.1".to_string(),
|
||||
|
|
@ -329,9 +865,7 @@ mod tests {
|
|||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let decision = evaluate_host_policy(&state, None, &request).await.unwrap();
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
|
|
@ -340,7 +874,6 @@ mod tests {
|
|||
decision: NetworkPolicyDecision::Deny,
|
||||
}
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
|
|||
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
|
||||
pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied";
|
||||
pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled";
|
||||
pub(crate) const REASON_UNIX_SOCKET_UNSUPPORTED: &str = "unix_socket_unsupported";
|
||||
|
|
|
|||
|
|
@ -38,6 +38,19 @@ const MAX_BLOCKED_EVENTS: usize = 200;
|
|||
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NetworkProxyAuditMetadata {
|
||||
pub conversation_id: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub user_account_id: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub originator: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub terminal_type: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HostBlockReason {
|
||||
Denied,
|
||||
|
|
@ -187,6 +200,7 @@ pub struct NetworkProxyState {
|
|||
state: Arc<RwLock<ConfigState>>,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkProxyState {
|
||||
|
|
@ -203,24 +217,57 @@ impl Clone for NetworkProxyState {
|
|||
state: self.state.clone(),
|
||||
reloader: self.reloader.clone(),
|
||||
blocked_request_observer: self.blocked_request_observer.clone(),
|
||||
audit_metadata: self.audit_metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkProxyState {
|
||||
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
|
||||
Self::with_reloader_and_blocked_observer(state, reloader, None)
|
||||
Self::with_reloader_and_audit_metadata(
|
||||
state,
|
||||
reloader,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_reloader_and_blocked_observer(
|
||||
state: ConfigState,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
||||
) -> Self {
|
||||
Self::with_reloader_and_audit_metadata_and_blocked_observer(
|
||||
state,
|
||||
reloader,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
blocked_request_observer,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_reloader_and_audit_metadata(
|
||||
state: ConfigState,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
) -> Self {
|
||||
Self::with_reloader_and_audit_metadata_and_blocked_observer(
|
||||
state,
|
||||
reloader,
|
||||
audit_metadata,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
|
||||
state: ConfigState,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
audit_metadata: NetworkProxyAuditMetadata,
|
||||
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
reloader,
|
||||
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
|
||||
audit_metadata,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +279,10 @@ impl NetworkProxyState {
|
|||
*observer = blocked_request_observer;
|
||||
}
|
||||
|
||||
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
|
||||
&self.audit_metadata
|
||||
}
|
||||
|
||||
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
|
||||
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
|
||||
// `config.toml` (including Codex-managed writes) take effect without a restart.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::config::NetworkMode;
|
||||
use crate::network_policy::BlockDecisionAuditEventArgs;
|
||||
use crate::network_policy::NetworkDecision;
|
||||
use crate::network_policy::NetworkDecisionSource;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
|
|
@ -6,6 +7,7 @@ use crate::network_policy::NetworkPolicyDecision;
|
|||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::emit_block_decision_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
|
|
@ -152,6 +154,15 @@ async fn handle_socks5_tcp(
|
|||
match app_state.enabled().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
emit_socks_block_decision_audit_event(
|
||||
&app_state,
|
||||
NetworkDecisionSource::ProxyState,
|
||||
REASON_PROXY_DISABLED,
|
||||
NetworkProtocol::Socks5Tcp,
|
||||
host.as_str(),
|
||||
port,
|
||||
client.as_deref(),
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
|
|
@ -185,6 +196,15 @@ async fn handle_socks5_tcp(
|
|||
|
||||
match app_state.network_mode().await {
|
||||
Ok(NetworkMode::Limited) => {
|
||||
emit_socks_block_decision_audit_event(
|
||||
&app_state,
|
||||
NetworkDecisionSource::ModeGuard,
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
NetworkProtocol::Socks5Tcp,
|
||||
host.as_str(),
|
||||
port,
|
||||
client.as_deref(),
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
|
|
@ -298,6 +318,15 @@ async fn inspect_socks5_udp(
|
|||
match state.enabled().await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
emit_socks_block_decision_audit_event(
|
||||
&state,
|
||||
NetworkDecisionSource::ProxyState,
|
||||
REASON_PROXY_DISABLED,
|
||||
NetworkProtocol::Socks5Udp,
|
||||
host.as_str(),
|
||||
port,
|
||||
client.as_deref(),
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_PROXY_DISABLED,
|
||||
|
|
@ -331,6 +360,15 @@ async fn inspect_socks5_udp(
|
|||
|
||||
match state.network_mode().await {
|
||||
Ok(NetworkMode::Limited) => {
|
||||
emit_socks_block_decision_audit_event(
|
||||
&state,
|
||||
NetworkDecisionSource::ModeGuard,
|
||||
REASON_METHOD_NOT_ALLOWED,
|
||||
NetworkProtocol::Socks5Udp,
|
||||
host.as_str(),
|
||||
port,
|
||||
client.as_deref(),
|
||||
);
|
||||
let details = PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
|
|
@ -413,9 +451,159 @@ async fn inspect_socks5_udp(
|
|||
}
|
||||
}
|
||||
|
||||
fn emit_socks_block_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
source: NetworkDecisionSource,
|
||||
reason: &str,
|
||||
protocol: NetworkProtocol,
|
||||
host: &str,
|
||||
port: u16,
|
||||
client_addr: Option<&str>,
|
||||
) {
|
||||
emit_block_decision_audit_event(
|
||||
state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source,
|
||||
reason,
|
||||
protocol,
|
||||
server_address: host,
|
||||
server_port: port,
|
||||
method: None,
|
||||
client_addr,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn policy_denied_error(reason: &str, details: &PolicyDecisionDetails<'_>) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
blocked_message_with_policy(reason, details),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME;
|
||||
use crate::network_policy::test_support::capture_events;
|
||||
use crate::network_policy::test_support::find_event_by_name;
|
||||
use crate::runtime::ConfigReloader;
|
||||
use crate::runtime::ConfigState;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
use crate::state::build_config_state;
|
||||
use async_trait::async_trait;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_core::extensions::Extensions;
|
||||
use rama_core::extensions::ExtensionsMut;
|
||||
use rama_net::address::HostWithPort;
|
||||
use rama_net::address::SocketAddress;
|
||||
use rama_socks5::server::udp::RelayDirection;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StaticReloader {
|
||||
state: ConfigState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigReloader for StaticReloader {
|
||||
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
|
||||
Ok(self.state.clone())
|
||||
}
|
||||
|
||||
fn source_label(&self) -> String {
|
||||
"static test reloader".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn state_for_settings(network: NetworkProxySettings) -> Arc<NetworkProxyState> {
|
||||
let config = NetworkProxyConfig { network };
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
|
||||
let reloader = Arc::new(StaticReloader {
|
||||
state: state.clone(),
|
||||
});
|
||||
Arc::new(NetworkProxyState::with_reloader(state, reloader))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn handle_socks5_tcp_emits_block_decision_for_proxy_disabled() {
|
||||
let state = state_for_settings(NetworkProxySettings {
|
||||
enabled: false,
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let mut request =
|
||||
TcpRequest::new(HostWithPort::try_from("example.com:443").expect("valid authority"));
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let (result, events) = capture_events(|| async {
|
||||
handle_socks5_tcp(request, TcpConnector::default(), None).await
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_err(), "proxy-disabled request should be denied");
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision event");
|
||||
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
|
||||
assert_eq!(event.field("network.policy.decision"), Some("deny"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("proxy_state"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_PROXY_DISABLED)
|
||||
);
|
||||
assert_eq!(
|
||||
event.field("network.transport.protocol"),
|
||||
Some("socks5_tcp")
|
||||
);
|
||||
assert_eq!(event.field("server.address"), Some("example.com"));
|
||||
assert_eq!(event.field("server.port"), Some("443"));
|
||||
assert_eq!(event.field("http.request.method"), Some("none"));
|
||||
assert_eq!(event.field("client.address"), Some("unknown"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() {
|
||||
let state = state_for_settings(NetworkProxySettings {
|
||||
enabled: true,
|
||||
mode: NetworkMode::Limited,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let request = RelayRequest {
|
||||
direction: RelayDirection::South,
|
||||
server_address: SocketAddress::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 53),
|
||||
payload: Default::default(),
|
||||
extensions: Extensions::new(),
|
||||
};
|
||||
|
||||
let (result, events) =
|
||||
capture_events(|| async { inspect_socks5_udp(request, state, None).await }).await;
|
||||
assert!(result.is_err(), "limited-mode UDP request should be denied");
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision event");
|
||||
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
|
||||
assert_eq!(event.field("network.policy.decision"), Some("deny"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_METHOD_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(
|
||||
event.field("network.transport.protocol"),
|
||||
Some("socks5_udp")
|
||||
);
|
||||
assert_eq!(event.field("server.address"), Some("93.184.216.34"));
|
||||
assert_eq!(event.field("server.port"), Some("53"));
|
||||
assert_eq!(event.field("http.request.method"), Some("none"));
|
||||
assert_eq!(event.field("client.address"), Some("unknown"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use std::sync::Arc;
|
|||
|
||||
pub use crate::runtime::BlockedRequest;
|
||||
pub use crate::runtime::BlockedRequestArgs;
|
||||
pub use crate::runtime::NetworkProxyAuditMetadata;
|
||||
pub use crate::runtime::NetworkProxyState;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::runtime::network_proxy_state_for_policy;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue