Skip to main content

nono_proxy/
audit.rs

1//! Audit logging for proxy requests.
2//!
3//! Logs all proxy requests with structured fields via `tracing`.
4//! Sensitive data (authorization headers, tokens, request bodies)
5//! is never included in audit logs.
6
7use nono::undo::{
8    NetworkAuditAuthMechanism, NetworkAuditAuthOutcome, NetworkAuditDecision,
9    NetworkAuditDenialCategory, NetworkAuditEvent, NetworkAuditInjectionMode, NetworkAuditMode,
10};
11use std::sync::{Arc, Mutex};
12use std::time::{SystemTime, UNIX_EPOCH};
13use tracing::{info, warn};
14
15/// Maximum number of in-memory network audit events kept per proxy session.
16const MAX_AUDIT_EVENTS: usize = 4096;
17
18/// Shared in-memory sink for network audit events.
19pub type SharedAuditLog = Arc<Mutex<Vec<NetworkAuditEvent>>>;
20
21/// Proxy mode for audit logging.
22#[derive(Debug, Clone, Copy)]
23pub enum ProxyMode {
24    /// CONNECT tunnel (host filtering only, no L7 visibility)
25    Connect,
26    /// CONNECT tunnel that the proxy terminated locally for L7 inspection
27    /// and/or credential injection.
28    ConnectIntercept,
29    /// Reverse proxy (credential injection)
30    Reverse,
31    /// External proxy passthrough (enterprise)
32    External,
33}
34
35/// Optional structured audit context attached to a proxy event.
36#[derive(Debug, Clone, Default)]
37pub struct EventContext<'a> {
38    pub route_id: Option<&'a str>,
39    pub auth_mechanism: Option<NetworkAuditAuthMechanism>,
40    pub auth_outcome: Option<NetworkAuditAuthOutcome>,
41    pub managed_credential_active: Option<bool>,
42    pub injection_mode: Option<NetworkAuditInjectionMode>,
43    pub denial_category: Option<NetworkAuditDenialCategory>,
44}
45
46impl std::fmt::Display for ProxyMode {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            ProxyMode::Connect => write!(f, "connect"),
50            ProxyMode::ConnectIntercept => write!(f, "connect_intercept"),
51            ProxyMode::Reverse => write!(f, "reverse"),
52            ProxyMode::External => write!(f, "external"),
53        }
54    }
55}
56
57/// Create a shared in-memory audit log.
58#[must_use]
59pub fn new_audit_log() -> SharedAuditLog {
60    Arc::new(Mutex::new(Vec::new()))
61}
62
63/// Drain all network audit events collected so far.
64#[must_use]
65pub fn drain_audit_events(audit_log: &SharedAuditLog) -> Vec<NetworkAuditEvent> {
66    match audit_log.lock() {
67        Ok(mut events) => events.drain(..).collect(),
68        Err(e) => {
69            warn!(
70                "Network audit log mutex poisoned while draining events: {}",
71                e
72            );
73            Vec::new()
74        }
75    }
76}
77
78fn now_unix_millis() -> u64 {
79    match SystemTime::now().duration_since(UNIX_EPOCH) {
80        Ok(duration) => {
81            let millis = duration.as_millis();
82            if millis > u128::from(u64::MAX) {
83                warn!("System clock millis exceeded u64::MAX; clamping audit timestamp");
84                u64::MAX
85            } else {
86                millis as u64
87            }
88        }
89        Err(e) => {
90            warn!(
91                "System clock before UNIX_EPOCH while generating audit timestamp: {}",
92                e
93            );
94            0
95        }
96    }
97}
98
99fn map_mode(mode: ProxyMode) -> NetworkAuditMode {
100    match mode {
101        ProxyMode::Connect => NetworkAuditMode::Connect,
102        ProxyMode::ConnectIntercept => NetworkAuditMode::ConnectIntercept,
103        ProxyMode::Reverse => NetworkAuditMode::Reverse,
104        ProxyMode::External => NetworkAuditMode::External,
105    }
106}
107
108fn push_event(audit_log: Option<&SharedAuditLog>, event: NetworkAuditEvent) {
109    let Some(audit_log) = audit_log else {
110        return;
111    };
112
113    match audit_log.lock() {
114        Ok(mut events) => {
115            if events.len() < MAX_AUDIT_EVENTS {
116                events.push(event);
117            } else {
118                warn!(
119                    "Network audit buffer full ({} events); dropping event",
120                    MAX_AUDIT_EVENTS
121                );
122            }
123        }
124        Err(e) => {
125            warn!(
126                "Network audit log mutex poisoned while recording event: {}",
127                e
128            );
129        }
130    }
131}
132
133/// Log an allowed proxy request.
134pub fn log_allowed(
135    audit_log: Option<&SharedAuditLog>,
136    mode: ProxyMode,
137    ctx: &EventContext<'_>,
138    host: &str,
139    port: u16,
140    method: &str,
141) {
142    info!(
143        target: "nono_proxy::audit",
144        mode = %mode,
145        host = host,
146        port = port,
147        method = method,
148        decision = "allow",
149        "proxy request allowed"
150    );
151
152    push_event(
153        audit_log,
154        NetworkAuditEvent {
155            timestamp_unix_ms: now_unix_millis(),
156            mode: map_mode(mode),
157            decision: NetworkAuditDecision::Allow,
158            route_id: ctx.route_id.map(str::to_string),
159            auth_mechanism: ctx.auth_mechanism.clone(),
160            auth_outcome: ctx.auth_outcome.clone(),
161            managed_credential_active: ctx.managed_credential_active,
162            injection_mode: ctx.injection_mode.clone(),
163            denial_category: None,
164            target: host.to_string(),
165            port: Some(port),
166            method: Some(method.to_string()),
167            path: None,
168            status: None,
169            reason: None,
170        },
171    );
172}
173
174/// Log a denied proxy request.
175pub fn log_denied(
176    audit_log: Option<&SharedAuditLog>,
177    mode: ProxyMode,
178    ctx: &EventContext<'_>,
179    host: &str,
180    port: u16,
181    reason: &str,
182) {
183    info!(
184        target: "nono_proxy::audit",
185        mode = %mode,
186        host = host,
187        port = port,
188        decision = "deny",
189        reason = reason,
190        "proxy request denied"
191    );
192
193    push_event(
194        audit_log,
195        NetworkAuditEvent {
196            timestamp_unix_ms: now_unix_millis(),
197            mode: map_mode(mode),
198            decision: NetworkAuditDecision::Deny,
199            route_id: ctx.route_id.map(str::to_string),
200            auth_mechanism: ctx.auth_mechanism.clone(),
201            auth_outcome: ctx.auth_outcome.clone(),
202            managed_credential_active: ctx.managed_credential_active,
203            injection_mode: ctx.injection_mode.clone(),
204            denial_category: ctx.denial_category.clone(),
205            target: host.to_string(),
206            port: Some(port),
207            method: None,
208            path: None,
209            status: None,
210            reason: Some(reason.to_string()),
211        },
212    );
213}
214
215/// Log an L7 request that the proxy decoded (reverse proxy or intercepted CONNECT).
216///
217/// Used for both `Reverse` and `ConnectIntercept` modes. `External` and
218/// `Connect` (transparent tunnel) modes have no L7 visibility and use
219/// `log_allowed`/`log_denied` instead.
220pub fn log_l7_request(
221    audit_log: Option<&SharedAuditLog>,
222    mode: ProxyMode,
223    ctx: &EventContext<'_>,
224    target: &str,
225    method: &str,
226    path: &str,
227    status: u16,
228) {
229    info!(
230        target: "nono_proxy::audit",
231        mode = %mode,
232        target = target,
233        method = method,
234        path = path,
235        status = status,
236        "l7 proxy response"
237    );
238
239    push_event(
240        audit_log,
241        NetworkAuditEvent {
242            timestamp_unix_ms: now_unix_millis(),
243            mode: map_mode(mode),
244            decision: NetworkAuditDecision::Allow,
245            route_id: ctx.route_id.map(str::to_string),
246            auth_mechanism: ctx.auth_mechanism.clone(),
247            auth_outcome: ctx.auth_outcome.clone(),
248            managed_credential_active: ctx.managed_credential_active,
249            injection_mode: ctx.injection_mode.clone(),
250            denial_category: None,
251            target: target.to_string(),
252            port: None,
253            method: Some(method.to_string()),
254            path: Some(path.to_string()),
255            status: Some(status),
256            reason: None,
257        },
258    );
259}
260
261/// Compatibility shim for the previous `log_reverse_proxy` API. New code
262/// should call [`log_l7_request`] directly with the appropriate
263/// [`ProxyMode`] instead.
264#[deprecated(since = "0.46.0", note = "use log_l7_request with ProxyMode::Reverse")]
265pub fn log_reverse_proxy(
266    audit_log: Option<&SharedAuditLog>,
267    service: &str,
268    method: &str,
269    path: &str,
270    status: u16,
271) {
272    log_l7_request(
273        audit_log,
274        ProxyMode::Reverse,
275        &EventContext {
276            route_id: Some(service),
277            ..EventContext::default()
278        },
279        service,
280        method,
281        path,
282        status,
283    );
284}
285
286#[cfg(test)]
287#[allow(clippy::unwrap_used)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn log_allowed_records_event() {
293        let log = new_audit_log();
294
295        log_allowed(
296            Some(&log),
297            ProxyMode::Connect,
298            &EventContext::default(),
299            "api.openai.com",
300            443,
301            "CONNECT",
302        );
303
304        let events = drain_audit_events(&log);
305        assert_eq!(events.len(), 1);
306        let event = &events[0];
307        assert_eq!(event.mode, NetworkAuditMode::Connect);
308        assert_eq!(event.decision, NetworkAuditDecision::Allow);
309        assert_eq!(event.route_id, None);
310        assert_eq!(event.auth_mechanism, None);
311        assert_eq!(event.target, "api.openai.com");
312        assert_eq!(event.port, Some(443));
313        assert_eq!(event.method.as_deref(), Some("CONNECT"));
314        assert!(event.timestamp_unix_ms > 0);
315    }
316
317    #[test]
318    fn log_denied_records_reason() {
319        let log = new_audit_log();
320
321        log_denied(
322            Some(&log),
323            ProxyMode::External,
324            &EventContext::default(),
325            "169.254.169.254",
326            80,
327            "blocked by metadata deny list",
328        );
329
330        let events = drain_audit_events(&log);
331        assert_eq!(events.len(), 1);
332        let event = &events[0];
333        assert_eq!(event.mode, NetworkAuditMode::External);
334        assert_eq!(event.decision, NetworkAuditDecision::Deny);
335        assert_eq!(event.route_id, None);
336        assert_eq!(event.auth_mechanism, None);
337        assert_eq!(
338            event.reason.as_deref(),
339            Some("blocked by metadata deny list")
340        );
341    }
342}