Skip to main content

vellaveto_engine/
exfil_path.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Data exfiltration path analysis.
9//!
10//! Combines signals from multiple detectors to identify complete
11//! exfiltration paths: data acquisition → staging → transmission.
12//! This is the integration layer that connects credential detection,
13//! DLP, network egress, and behavioral analysis into end-to-end
14//! exfiltration chain detection.
15
16use vellaveto_types::provenance::SinkClass;
17
18/// An exfiltration path finding.
19#[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    /// Credential read → external transmission.
30    CredentialExfil,
31    /// Sensitive file read → encoding → network egress.
32    FileExfil,
33    /// System enumeration → data collection → batch transmission.
34    ReconExfil,
35    /// Memory/context extraction → external delivery.
36    ContextExfil,
37}
38
39/// A stage in an exfiltration path.
40#[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    /// Data acquisition (read sensitive data).
50    Acquire,
51    /// Data staging (encode, compress, split).
52    Stage,
53    /// Data transmission (network egress).
54    Transmit,
55}
56
57/// Track tool calls and identify complete exfiltration paths.
58pub struct ExfilPathTracker {
59    /// Recent data acquisition events.
60    acquisitions: Vec<AcquisitionEvent>,
61    /// Recent transmission events.
62    transmissions: Vec<TransmissionEvent>,
63    /// Detected complete paths.
64    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
83/// Maximum tracked events.
84const MAX_EVENTS: usize = 200;
85/// Time window for path detection (ms).
86const 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    /// Record a tool call and check for exfiltration path completion.
98    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        // Record acquisition
116        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        // Record transmission and check for complete paths
126        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            // Check for complete exfiltration paths
136            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        // Prune old events
187        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    /// Get all detected exfiltration paths.
196    pub fn findings(&self) -> &[ExfilPathFinding] {
197        &self.findings
198    }
199
200    /// Maximum severity across all findings.
201    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}