1use vellaveto_types::provenance::SinkClass;
21
22const MAX_CHAIN_LEN: usize = 100;
24
25#[derive(Debug, Clone)]
27struct ChainStep {
28 tool_name: String,
29 sink_class: SinkClass,
30 reads_sensitive: bool,
31 #[allow(dead_code)]
32 writes_external: bool,
33 #[allow(dead_code)]
34 executes_code: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum HarmfulChainPattern {
40 ReadThenExfiltrate,
42 CredentialHarvest,
44 ConfigThenExecute,
46 ReconThenExploit,
48 PrivilegeChain,
50}
51
52impl HarmfulChainPattern {
53 pub fn severity(&self) -> u32 {
54 match self {
55 Self::ReadThenExfiltrate => 90,
56 Self::CredentialHarvest => 95,
57 Self::ConfigThenExecute => 85,
58 Self::ReconThenExploit => 80,
59 Self::PrivilegeChain => 75,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct HarmfulChainFinding {
67 pub pattern: HarmfulChainPattern,
68 pub severity: u32,
69 pub chain_length: usize,
70 pub description: String,
71}
72
73pub struct CumulativeHarmTracker {
75 chain: Vec<ChainStep>,
76 findings: Vec<HarmfulChainFinding>,
77}
78
79impl CumulativeHarmTracker {
80 pub fn new() -> Self {
81 Self {
82 chain: Vec::new(),
83 findings: Vec::new(),
84 }
85 }
86
87 pub fn record_and_check(
89 &mut self,
90 tool_name: &str,
91 sink_class: SinkClass,
92 target_paths: &[String],
93 target_domains: &[String],
94 ) -> Vec<HarmfulChainFinding> {
95 let reads_sensitive = is_sensitive_read(tool_name, target_paths);
96 let writes_external =
97 !target_domains.is_empty() || matches!(sink_class, SinkClass::NetworkEgress);
98 let executes_code = matches!(sink_class, SinkClass::CodeExecution);
99
100 if self.chain.len() < MAX_CHAIN_LEN {
101 self.chain.push(ChainStep {
102 tool_name: tool_name[..tool_name.len().min(256)].to_string(),
103 sink_class,
104 reads_sensitive,
105 writes_external,
106 executes_code,
107 });
108 }
109
110 let mut new_findings = Vec::new();
111
112 if writes_external {
114 for prev in self.chain.iter().rev().skip(1).take(5) {
115 if prev.reads_sensitive {
116 let finding = HarmfulChainFinding {
117 pattern: HarmfulChainPattern::ReadThenExfiltrate,
118 severity: HarmfulChainPattern::ReadThenExfiltrate.severity(),
119 chain_length: 2,
120 description: format!(
121 "'{}' (sensitive read) → '{}' (external write)",
122 prev.tool_name, tool_name
123 ),
124 };
125 new_findings.push(finding);
126 break;
127 }
128 }
129 }
130
131 if !target_domains.is_empty() || writes_external {
133 for prev in self.chain.iter().rev().skip(1).take(3) {
134 if is_credential_read(&prev.tool_name, &[]) {
135 let finding = HarmfulChainFinding {
136 pattern: HarmfulChainPattern::CredentialHarvest,
137 severity: HarmfulChainPattern::CredentialHarvest.severity(),
138 chain_length: 2,
139 description: format!(
140 "'{}' (credential read) → '{}' (credential use)",
141 prev.tool_name, tool_name
142 ),
143 };
144 new_findings.push(finding);
145 break;
146 }
147 }
148 }
149
150 if executes_code {
152 for prev in self.chain.iter().rev().skip(1).take(5) {
153 if is_config_write(&prev.tool_name) {
154 let finding = HarmfulChainFinding {
155 pattern: HarmfulChainPattern::ConfigThenExecute,
156 severity: HarmfulChainPattern::ConfigThenExecute.severity(),
157 chain_length: 2,
158 description: format!(
159 "'{}' (config write) → '{}' (code execution)",
160 prev.tool_name, tool_name
161 ),
162 };
163 new_findings.push(finding);
164 break;
165 }
166 }
167 }
168
169 if self.chain.len() >= 3 {
171 let last_3: Vec<u8> = self
172 .chain
173 .iter()
174 .rev()
175 .take(3)
176 .map(|s| s.sink_class.rank())
177 .collect();
178 if last_3.len() == 3
179 && last_3[0] > last_3[1]
180 && last_3[1] > last_3[2]
181 && last_3[0] >= SinkClass::CodeExecution.rank()
182 {
183 new_findings.push(HarmfulChainFinding {
184 pattern: HarmfulChainPattern::PrivilegeChain,
185 severity: HarmfulChainPattern::PrivilegeChain.severity(),
186 chain_length: 3,
187 description: "escalating sink class chain detected".to_string(),
188 });
189 }
190 }
191
192 self.findings.extend(new_findings.clone());
193 new_findings
194 }
195
196 pub fn max_severity(&self) -> u32 {
198 self.findings.iter().map(|f| f.severity).max().unwrap_or(0)
199 }
200
201 pub fn findings(&self) -> &[HarmfulChainFinding] {
203 &self.findings
204 }
205}
206
207impl Default for CumulativeHarmTracker {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213fn is_sensitive_read(tool_name: &str, paths: &[String]) -> bool {
214 let sensitive_patterns = [
215 ".aws",
216 ".ssh",
217 ".env",
218 "credentials",
219 "secret",
220 "passwd",
221 "shadow",
222 ];
223 paths
224 .iter()
225 .any(|p| sensitive_patterns.iter().any(|s| p.contains(s)))
226 || tool_name.contains("credential")
227 || tool_name.contains("secret")
228}
229
230fn is_credential_read(tool_name: &str, _paths: &[String]) -> bool {
231 tool_name.contains("credential")
232 || tool_name.contains("secret")
233 || tool_name.contains("password")
234 || tool_name.contains("token")
235 || tool_name.contains("key")
236}
237
238fn is_config_write(tool_name: &str) -> bool {
239 (tool_name.contains("write") || tool_name.contains("modify") || tool_name.contains("set"))
240 && (tool_name.contains("config")
241 || tool_name.contains("setting")
242 || tool_name.contains("hook"))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_read_then_exfiltrate() {
251 let mut tracker = CumulativeHarmTracker::new();
252 tracker.record_and_check(
253 "read_file",
254 SinkClass::ReadOnly,
255 &["/home/user/.aws/credentials".to_string()],
256 &[],
257 );
258 let findings = tracker.record_and_check(
259 "http_post",
260 SinkClass::NetworkEgress,
261 &[],
262 &["evil.com".to_string()],
263 );
264 assert!(findings
265 .iter()
266 .any(|f| f.pattern == HarmfulChainPattern::ReadThenExfiltrate));
267 }
268
269 #[test]
270 fn test_credential_harvest() {
271 let mut tracker = CumulativeHarmTracker::new();
272 tracker.record_and_check("get_secret_key", SinkClass::ReadOnly, &[], &[]);
273 let findings = tracker.record_and_check(
274 "http_request",
275 SinkClass::NetworkEgress,
276 &[],
277 &["api.target.com".to_string()],
278 );
279 assert!(findings
280 .iter()
281 .any(|f| f.pattern == HarmfulChainPattern::CredentialHarvest));
282 }
283
284 #[test]
285 fn test_config_then_execute() {
286 let mut tracker = CumulativeHarmTracker::new();
287 tracker.record_and_check("write_config", SinkClass::FilesystemWrite, &[], &[]);
288 let findings =
289 tracker.record_and_check("execute_command", SinkClass::CodeExecution, &[], &[]);
290 assert!(findings
291 .iter()
292 .any(|f| f.pattern == HarmfulChainPattern::ConfigThenExecute));
293 }
294
295 #[test]
296 fn test_privilege_chain() {
297 let mut tracker = CumulativeHarmTracker::new();
298 tracker.record_and_check("list_files", SinkClass::ReadOnly, &[], &[]);
299 tracker.record_and_check("write_file", SinkClass::FilesystemWrite, &[], &[]);
300 let findings = tracker.record_and_check("execute_cmd", SinkClass::CodeExecution, &[], &[]);
301 assert!(findings
302 .iter()
303 .any(|f| f.pattern == HarmfulChainPattern::PrivilegeChain));
304 }
305
306 #[test]
307 fn test_benign_chain_no_findings() {
308 let mut tracker = CumulativeHarmTracker::new();
309 tracker.record_and_check(
310 "read_file",
311 SinkClass::ReadOnly,
312 &["/tmp/readme.md".to_string()],
313 &[],
314 );
315 tracker.record_and_check(
316 "read_file",
317 SinkClass::ReadOnly,
318 &["/tmp/notes.txt".to_string()],
319 &[],
320 );
321 let findings = tracker.record_and_check(
322 "write_file",
323 SinkClass::FilesystemWrite,
324 &["/tmp/output.txt".to_string()],
325 &[],
326 );
327 assert!(
328 findings.is_empty(),
329 "Benign read→read→write should not flag"
330 );
331 }
332
333 #[test]
334 fn test_max_severity() {
335 let mut tracker = CumulativeHarmTracker::new();
336 tracker.record_and_check("get_secret_key", SinkClass::ReadOnly, &[], &[]);
337 tracker.record_and_check(
338 "http_post",
339 SinkClass::NetworkEgress,
340 &[],
341 &["evil.com".to_string()],
342 );
343 assert!(tracker.max_severity() >= 90);
344 }
345}