1use vellaveto_types::provenance::SinkClass;
17
18#[derive(Debug, Clone)]
20pub struct ExfilPathFinding {
21 pub path_type: ExfilPathType,
22 pub severity: u32,
23 pub stages: Vec<ExfilStage>,
24 pub description: String,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ExfilPathType {
29 CredentialExfil,
31 FileExfil,
33 ReconExfil,
35 ContextExfil,
37}
38
39#[derive(Debug, Clone)]
41pub struct ExfilStage {
42 pub stage_type: ExfilStageType,
43 pub tool_name: String,
44 pub detail: String,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ExfilStageType {
49 Acquire,
51 Stage,
53 Transmit,
55}
56
57pub struct ExfilPathTracker {
59 acquisitions: Vec<AcquisitionEvent>,
61 transmissions: Vec<TransmissionEvent>,
63 findings: Vec<ExfilPathFinding>,
65}
66
67#[derive(Debug, Clone)]
68struct AcquisitionEvent {
69 tool_name: String,
70 is_credential: bool,
71 is_sensitive_file: bool,
72 timestamp_ms: u64,
73}
74
75#[derive(Debug, Clone)]
76#[allow(dead_code)]
77struct TransmissionEvent {
78 tool_name: String,
79 has_external_domain: bool,
80 timestamp_ms: u64,
81}
82
83const MAX_EVENTS: usize = 200;
85const PATH_WINDOW_MS: u64 = 30_000;
87
88impl ExfilPathTracker {
89 pub fn new() -> Self {
90 Self {
91 acquisitions: Vec::new(),
92 transmissions: Vec::new(),
93 findings: Vec::new(),
94 }
95 }
96
97 pub fn record_call(
99 &mut self,
100 tool_name: &str,
101 sink_class: SinkClass,
102 target_paths: &[String],
103 target_domains: &[String],
104 ) -> Vec<ExfilPathFinding> {
105 let now = now_ms();
106 let mut new_findings = Vec::new();
107
108 let is_credential = is_credential_path(target_paths)
109 || tool_name.contains("credential")
110 || tool_name.contains("secret");
111 let is_sensitive = is_sensitive_path(target_paths);
112 let is_network =
113 matches!(sink_class, SinkClass::NetworkEgress) || !target_domains.is_empty();
114
115 if (is_credential || is_sensitive) && self.acquisitions.len() < MAX_EVENTS {
117 self.acquisitions.push(AcquisitionEvent {
118 tool_name: tool_name[..tool_name.len().min(256)].to_string(),
119 is_credential,
120 is_sensitive_file: is_sensitive,
121 timestamp_ms: now,
122 });
123 }
124
125 if is_network {
127 if self.transmissions.len() < MAX_EVENTS {
128 self.transmissions.push(TransmissionEvent {
129 tool_name: tool_name[..tool_name.len().min(256)].to_string(),
130 has_external_domain: !target_domains.is_empty(),
131 timestamp_ms: now,
132 });
133 }
134
135 let cutoff = now.saturating_sub(PATH_WINDOW_MS);
137 for acq in &self.acquisitions {
138 if acq.timestamp_ms < cutoff {
139 continue;
140 }
141 if acq.is_credential {
142 let finding = ExfilPathFinding {
143 path_type: ExfilPathType::CredentialExfil,
144 severity: 95,
145 stages: vec![
146 ExfilStage {
147 stage_type: ExfilStageType::Acquire,
148 tool_name: acq.tool_name.clone(),
149 detail: "credential acquisition".to_string(),
150 },
151 ExfilStage {
152 stage_type: ExfilStageType::Transmit,
153 tool_name: tool_name.to_string(),
154 detail: "network egress".to_string(),
155 },
156 ],
157 description: format!(
158 "Credential exfil: '{}' → '{}'",
159 acq.tool_name, tool_name
160 ),
161 };
162 new_findings.push(finding);
163 } else if acq.is_sensitive_file {
164 let finding = ExfilPathFinding {
165 path_type: ExfilPathType::FileExfil,
166 severity: 85,
167 stages: vec![
168 ExfilStage {
169 stage_type: ExfilStageType::Acquire,
170 tool_name: acq.tool_name.clone(),
171 detail: "sensitive file read".to_string(),
172 },
173 ExfilStage {
174 stage_type: ExfilStageType::Transmit,
175 tool_name: tool_name.to_string(),
176 detail: "network egress".to_string(),
177 },
178 ],
179 description: format!("File exfil: '{}' → '{}'", acq.tool_name, tool_name),
180 };
181 new_findings.push(finding);
182 }
183 }
184 }
185
186 let cutoff = now.saturating_sub(PATH_WINDOW_MS * 2);
188 self.acquisitions.retain(|e| e.timestamp_ms >= cutoff);
189 self.transmissions.retain(|e| e.timestamp_ms >= cutoff);
190
191 self.findings.extend(new_findings.clone());
192 new_findings
193 }
194
195 pub fn findings(&self) -> &[ExfilPathFinding] {
197 &self.findings
198 }
199
200 pub fn max_severity(&self) -> u32 {
202 self.findings.iter().map(|f| f.severity).max().unwrap_or(0)
203 }
204}
205
206impl Default for ExfilPathTracker {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212fn is_credential_path(paths: &[String]) -> bool {
213 let cred_patterns = [
214 ".aws",
215 ".ssh",
216 ".env",
217 "credentials",
218 "id_rsa",
219 "id_ed25519",
220 ".npmrc",
221 ".netrc",
222 ];
223 paths
224 .iter()
225 .any(|p| cred_patterns.iter().any(|c| p.contains(c)))
226}
227
228fn is_sensitive_path(paths: &[String]) -> bool {
229 let sensitive = [
230 "/etc/shadow",
231 "/etc/passwd",
232 "secrets",
233 "private",
234 ".config",
235 ".kube",
236 ];
237 paths
238 .iter()
239 .any(|p| sensitive.iter().any(|s| p.contains(s)))
240}
241
242fn now_ms() -> u64 {
243 std::time::SystemTime::now()
244 .duration_since(std::time::UNIX_EPOCH)
245 .map(|d| d.as_millis() as u64)
246 .unwrap_or(0)
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_credential_exfil_path() {
255 let mut tracker = ExfilPathTracker::new();
256 tracker.record_call(
257 "read_file",
258 SinkClass::ReadOnly,
259 &["/home/user/.aws/credentials".to_string()],
260 &[],
261 );
262 let findings = tracker.record_call(
263 "http_post",
264 SinkClass::NetworkEgress,
265 &[],
266 &["evil.com".to_string()],
267 );
268 assert!(findings
269 .iter()
270 .any(|f| f.path_type == ExfilPathType::CredentialExfil));
271 assert!(findings[0].severity >= 90);
272 }
273
274 #[test]
275 fn test_file_exfil_path() {
276 let mut tracker = ExfilPathTracker::new();
277 tracker.record_call(
278 "read_file",
279 SinkClass::ReadOnly,
280 &["/etc/shadow".to_string()],
281 &[],
282 );
283 let findings = tracker.record_call(
284 "send_data",
285 SinkClass::NetworkEgress,
286 &[],
287 &["attacker.com".to_string()],
288 );
289 assert!(findings
290 .iter()
291 .any(|f| f.path_type == ExfilPathType::FileExfil));
292 }
293
294 #[test]
295 fn test_no_path_without_acquisition() {
296 let mut tracker = ExfilPathTracker::new();
297 let findings = tracker.record_call(
298 "http_post",
299 SinkClass::NetworkEgress,
300 &[],
301 &["legit.com".to_string()],
302 );
303 assert!(findings.is_empty());
304 }
305
306 #[test]
307 fn test_benign_read_write_no_exfil() {
308 let mut tracker = ExfilPathTracker::new();
309 tracker.record_call(
310 "read_file",
311 SinkClass::ReadOnly,
312 &["/tmp/notes.txt".to_string()],
313 &[],
314 );
315 let findings = tracker.record_call(
316 "write_file",
317 SinkClass::FilesystemWrite,
318 &["/tmp/output.txt".to_string()],
319 &[],
320 );
321 assert!(findings.is_empty());
322 }
323
324 #[test]
325 fn test_max_severity() {
326 let mut tracker = ExfilPathTracker::new();
327 tracker.record_call(
328 "get_secret",
329 SinkClass::ReadOnly,
330 &["/home/user/.ssh/id_rsa".to_string()],
331 &[],
332 );
333 tracker.record_call(
334 "upload",
335 SinkClass::NetworkEgress,
336 &[],
337 &["evil.com".to_string()],
338 );
339 assert!(tracker.max_severity() >= 90);
340 }
341}