1use crate::types::{EventValue, FraudSignature, SignatureMatch, SignaturePattern, UserEvent};
7use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
18pub struct FraudSignatureDetection {
19 metadata: KernelMetadata,
20}
21
22impl Default for FraudSignatureDetection {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28impl FraudSignatureDetection {
29 #[must_use]
31 pub fn new() -> Self {
32 Self {
33 metadata: KernelMetadata::ring(
34 "behavioral/fraud-signatures",
35 Domain::BehavioralAnalytics,
36 )
37 .with_description("Known fraud pattern signature matching")
38 .with_throughput(150_000)
39 .with_latency_us(30.0),
40 }
41 }
42
43 pub fn compute(events: &[UserEvent], signatures: &[FraudSignature]) -> Vec<SignatureMatch> {
49 let mut matches = Vec::new();
50
51 for signature in signatures.iter().filter(|s| s.active) {
52 if let Some(match_result) = Self::match_signature(events, signature) {
53 matches.push(match_result);
54 }
55 }
56
57 matches.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
59
60 matches
61 }
62
63 fn match_signature(events: &[UserEvent], signature: &FraudSignature) -> Option<SignatureMatch> {
65 match &signature.pattern {
66 SignaturePattern::EventSequence(sequence) => {
67 Self::match_event_sequence(events, signature, sequence)
68 }
69 SignaturePattern::EventAttributes(event_type, attrs) => {
70 Self::match_event_attributes(events, signature, event_type, attrs)
71 }
72 SignaturePattern::TimeWindow {
73 events: event_types,
74 window_secs,
75 } => Self::match_time_window(events, signature, event_types, *window_secs),
76 SignaturePattern::CountThreshold {
77 event_type,
78 count,
79 window_secs,
80 } => Self::match_count_threshold(events, signature, event_type, *count, *window_secs),
81 SignaturePattern::Regex(pattern) => Self::match_regex(events, signature, pattern),
82 }
83 }
84
85 fn match_event_sequence(
87 events: &[UserEvent],
88 signature: &FraudSignature,
89 sequence: &[String],
90 ) -> Option<SignatureMatch> {
91 if sequence.is_empty() || events.len() < sequence.len() {
92 return None;
93 }
94
95 let mut sorted_events: Vec<_> = events.iter().collect();
97 sorted_events.sort_by_key(|e| e.timestamp);
98
99 let mut best_match: Option<(Vec<u64>, f64)> = None;
101
102 for window in sorted_events.windows(sequence.len()) {
103 let mut matched = true;
104 let mut matched_ids = Vec::new();
105
106 for (i, expected_type) in sequence.iter().enumerate() {
107 if window[i].event_type != *expected_type {
108 matched = false;
109 break;
110 }
111 matched_ids.push(window[i].id);
112 }
113
114 if matched {
115 let time_span =
117 window.last().unwrap().timestamp - window.first().unwrap().timestamp;
118 let time_score = if time_span < 300 {
119 100.0 } else if time_span < 3600 {
121 80.0
122 } else if time_span < 86400 {
123 60.0
124 } else {
125 40.0
126 };
127
128 let score = time_score * (signature.severity / 100.0);
129
130 if best_match.is_none() || score > best_match.as_ref().unwrap().1 {
131 best_match = Some((matched_ids, score));
132 }
133 }
134 }
135
136 best_match.map(|(matched_events, score)| SignatureMatch {
137 signature_id: signature.id,
138 signature_name: signature.name.clone(),
139 score,
140 matched_events,
141 details: format!("Event sequence matched: {:?}", sequence),
142 })
143 }
144
145 fn match_event_attributes(
147 events: &[UserEvent],
148 signature: &FraudSignature,
149 event_type: &str,
150 required_attrs: &HashMap<String, EventValue>,
151 ) -> Option<SignatureMatch> {
152 let mut matched_events = Vec::new();
153
154 for event in events {
155 if event.event_type != event_type {
156 continue;
157 }
158
159 let attrs_match = required_attrs.iter().all(|(key, expected_value)| {
160 event
161 .attributes
162 .get(key)
163 .is_some_and(|actual| Self::values_match(expected_value, actual))
164 });
165
166 if attrs_match {
167 matched_events.push(event.id);
168 }
169 }
170
171 if matched_events.is_empty() {
172 return None;
173 }
174
175 let score =
176 signature.severity * (matched_events.len() as f64 / events.len() as f64).min(1.0);
177
178 Some(SignatureMatch {
179 signature_id: signature.id,
180 signature_name: signature.name.clone(),
181 score,
182 matched_events,
183 details: format!(
184 "Events of type '{}' matched with required attributes",
185 event_type
186 ),
187 })
188 }
189
190 fn match_time_window(
192 events: &[UserEvent],
193 signature: &FraudSignature,
194 event_types: &[String],
195 window_secs: u64,
196 ) -> Option<SignatureMatch> {
197 if event_types.is_empty() {
198 return None;
199 }
200
201 let mut sorted_events: Vec<_> = events.iter().collect();
203 sorted_events.sort_by_key(|e| e.timestamp);
204
205 let mut best_match: Option<(Vec<u64>, f64)> = None;
206
207 for (i, start_event) in sorted_events.iter().enumerate() {
209 let window_end = start_event.timestamp + window_secs;
210
211 let window_events: Vec<_> = sorted_events[i..]
213 .iter()
214 .take_while(|e| e.timestamp <= window_end)
215 .collect();
216
217 let found_types: std::collections::HashSet<_> =
219 window_events.iter().map(|e| &e.event_type).collect();
220
221 let all_present = event_types.iter().all(|t| found_types.contains(t));
222
223 if all_present {
224 let matched_ids: Vec<_> = window_events.iter().map(|e| e.id).collect();
225 let actual_span = window_events.last().unwrap().timestamp
226 - window_events.first().unwrap().timestamp;
227
228 let compression_ratio = 1.0 - (actual_span as f64 / window_secs as f64);
230 let score = signature.severity * (0.5 + compression_ratio * 0.5);
231
232 if best_match.is_none() || score > best_match.as_ref().unwrap().1 {
233 best_match = Some((matched_ids, score));
234 }
235 }
236 }
237
238 best_match.map(|(matched_events, score)| SignatureMatch {
239 signature_id: signature.id,
240 signature_name: signature.name.clone(),
241 score,
242 matched_events,
243 details: format!(
244 "Events {:?} found within {} second window",
245 event_types, window_secs
246 ),
247 })
248 }
249
250 fn match_count_threshold(
252 events: &[UserEvent],
253 signature: &FraudSignature,
254 event_type: &str,
255 threshold: u32,
256 window_secs: u64,
257 ) -> Option<SignatureMatch> {
258 let mut relevant_events: Vec<_> = events
260 .iter()
261 .filter(|e| e.event_type == event_type)
262 .collect();
263
264 if relevant_events.len() < threshold as usize {
265 return None;
266 }
267
268 relevant_events.sort_by_key(|e| e.timestamp);
269
270 let mut best_match: Option<(Vec<u64>, u32, f64)> = None;
271
272 for (i, start_event) in relevant_events.iter().enumerate() {
274 let window_end = start_event.timestamp + window_secs;
275
276 let window_events: Vec<_> = relevant_events[i..]
277 .iter()
278 .take_while(|e| e.timestamp <= window_end)
279 .collect();
280
281 let count = window_events.len() as u32;
282
283 if count >= threshold {
284 let matched_ids: Vec<_> = window_events.iter().map(|e| e.id).collect();
285
286 let excess_ratio = (count as f64 / threshold as f64) - 1.0;
288 let score = signature.severity * (0.5 + excess_ratio.min(1.0) * 0.5);
289
290 if best_match.is_none() || count > best_match.as_ref().unwrap().1 {
291 best_match = Some((matched_ids, count, score));
292 }
293 }
294 }
295
296 best_match.map(|(matched_events, count, score)| SignatureMatch {
297 signature_id: signature.id,
298 signature_name: signature.name.clone(),
299 score,
300 matched_events,
301 details: format!(
302 "Count threshold exceeded: {} '{}' events in {} seconds (threshold: {})",
303 count, event_type, window_secs, threshold
304 ),
305 })
306 }
307
308 fn match_regex(
310 events: &[UserEvent],
311 signature: &FraudSignature,
312 pattern: &str,
313 ) -> Option<SignatureMatch> {
314 let mut matched_events = Vec::new();
316
317 for event in events {
318 if event.event_type.contains(pattern) {
320 matched_events.push(event.id);
321 continue;
322 }
323
324 for value in event.attributes.values() {
326 if Self::value_contains_pattern(value, pattern) {
327 matched_events.push(event.id);
328 break;
329 }
330 }
331 }
332
333 if matched_events.is_empty() {
334 return None;
335 }
336
337 let match_count = matched_events.len();
338 let score = signature.severity * (match_count as f64 / events.len() as f64).min(1.0);
339
340 Some(SignatureMatch {
341 signature_id: signature.id,
342 signature_name: signature.name.clone(),
343 score,
344 matched_events,
345 details: format!("Pattern '{}' matched in {} events", pattern, match_count),
346 })
347 }
348
349 fn values_match(expected: &EventValue, actual: &EventValue) -> bool {
351 match (expected, actual) {
352 (EventValue::String(e), EventValue::String(a)) => e == a,
353 (EventValue::Number(e), EventValue::Number(a)) => (e - a).abs() < 0.0001,
354 (EventValue::Bool(e), EventValue::Bool(a)) => e == a,
355 (EventValue::List(e), EventValue::List(a)) => {
356 e.len() == a.len()
357 && e.iter()
358 .zip(a.iter())
359 .all(|(ev, av)| Self::values_match(ev, av))
360 }
361 _ => false,
362 }
363 }
364
365 fn value_contains_pattern(value: &EventValue, pattern: &str) -> bool {
367 match value {
368 EventValue::String(s) => s.contains(pattern),
369 EventValue::List(items) => items
370 .iter()
371 .any(|v| Self::value_contains_pattern(v, pattern)),
372 _ => false,
373 }
374 }
375
376 pub fn standard_signatures() -> Vec<FraudSignature> {
378 vec![
379 FraudSignature {
380 id: 1,
381 name: "Rapid Login Attempts".to_string(),
382 pattern: SignaturePattern::CountThreshold {
383 event_type: "login_attempt".to_string(),
384 count: 5,
385 window_secs: 60,
386 },
387 severity: 70.0,
388 active: true,
389 },
390 FraudSignature {
391 id: 2,
392 name: "Account Takeover Sequence".to_string(),
393 pattern: SignaturePattern::EventSequence(vec![
394 "password_reset".to_string(),
395 "login".to_string(),
396 "profile_change".to_string(),
397 ]),
398 severity: 90.0,
399 active: true,
400 },
401 FraudSignature {
402 id: 3,
403 name: "Suspicious Time Window".to_string(),
404 pattern: SignaturePattern::TimeWindow {
405 events: vec![
406 "login".to_string(),
407 "high_value_purchase".to_string(),
408 "logout".to_string(),
409 ],
410 window_secs: 300,
411 },
412 severity: 80.0,
413 active: true,
414 },
415 FraudSignature {
416 id: 4,
417 name: "Gift Card Fraud Pattern".to_string(),
418 pattern: SignaturePattern::Regex("gift.*card".to_string()),
419 severity: 60.0,
420 active: true,
421 },
422 ]
423 }
424}
425
426impl GpuKernel for FraudSignatureDetection {
427 fn metadata(&self) -> &KernelMetadata {
428 &self.metadata
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn create_login_attack_events() -> Vec<UserEvent> {
437 let base_ts = 1700000000u64;
438 (0..10)
439 .map(|i| UserEvent {
440 id: i as u64,
441 user_id: 100,
442 event_type: "login_attempt".to_string(),
443 timestamp: base_ts + (i as u64 * 5), attributes: HashMap::new(),
445 session_id: None,
446 device_id: Some("unknown".to_string()),
447 ip_address: Some("10.0.0.1".to_string()),
448 location: Some("XX".to_string()),
449 })
450 .collect()
451 }
452
453 fn create_account_takeover_events() -> Vec<UserEvent> {
454 let base_ts = 1700000000u64;
455 vec![
456 UserEvent {
457 id: 1,
458 user_id: 100,
459 event_type: "password_reset".to_string(),
460 timestamp: base_ts,
461 attributes: HashMap::new(),
462 session_id: None,
463 device_id: Some("new_device".to_string()),
464 ip_address: None,
465 location: None,
466 },
467 UserEvent {
468 id: 2,
469 user_id: 100,
470 event_type: "login".to_string(),
471 timestamp: base_ts + 60,
472 attributes: HashMap::new(),
473 session_id: Some(1),
474 device_id: Some("new_device".to_string()),
475 ip_address: None,
476 location: None,
477 },
478 UserEvent {
479 id: 3,
480 user_id: 100,
481 event_type: "profile_change".to_string(),
482 timestamp: base_ts + 120,
483 attributes: HashMap::new(),
484 session_id: Some(1),
485 device_id: Some("new_device".to_string()),
486 ip_address: None,
487 location: None,
488 },
489 ]
490 }
491
492 #[test]
493 fn test_fraud_signature_metadata() {
494 let kernel = FraudSignatureDetection::new();
495 assert_eq!(kernel.metadata().id, "behavioral/fraud-signatures");
496 assert_eq!(kernel.metadata().domain, Domain::BehavioralAnalytics);
497 }
498
499 #[test]
500 fn test_count_threshold_detection() {
501 let events = create_login_attack_events();
502 let signatures = vec![FraudSignature {
503 id: 1,
504 name: "Rapid Login Attempts".to_string(),
505 pattern: SignaturePattern::CountThreshold {
506 event_type: "login_attempt".to_string(),
507 count: 5,
508 window_secs: 60,
509 },
510 severity: 70.0,
511 active: true,
512 }];
513
514 let matches = FraudSignatureDetection::compute(&events, &signatures);
515
516 assert!(!matches.is_empty(), "Should detect rapid login pattern");
517 assert_eq!(matches[0].signature_id, 1);
518 assert!(matches[0].score > 50.0);
519 }
520
521 #[test]
522 fn test_sequence_detection() {
523 let events = create_account_takeover_events();
524 let signatures = vec![FraudSignature {
525 id: 2,
526 name: "Account Takeover Sequence".to_string(),
527 pattern: SignaturePattern::EventSequence(vec![
528 "password_reset".to_string(),
529 "login".to_string(),
530 "profile_change".to_string(),
531 ]),
532 severity: 90.0,
533 active: true,
534 }];
535
536 let matches = FraudSignatureDetection::compute(&events, &signatures);
537
538 assert!(
539 !matches.is_empty(),
540 "Should detect account takeover sequence"
541 );
542 assert_eq!(matches[0].signature_id, 2);
543 assert_eq!(matches[0].matched_events.len(), 3);
544 }
545
546 #[test]
547 fn test_time_window_detection() {
548 let base_ts = 1700000000u64;
549 let events = vec![
550 UserEvent {
551 id: 1,
552 user_id: 100,
553 event_type: "login".to_string(),
554 timestamp: base_ts,
555 attributes: HashMap::new(),
556 session_id: Some(1),
557 device_id: None,
558 ip_address: None,
559 location: None,
560 },
561 UserEvent {
562 id: 2,
563 user_id: 100,
564 event_type: "high_value_purchase".to_string(),
565 timestamp: base_ts + 120,
566 attributes: HashMap::new(),
567 session_id: Some(1),
568 device_id: None,
569 ip_address: None,
570 location: None,
571 },
572 UserEvent {
573 id: 3,
574 user_id: 100,
575 event_type: "logout".to_string(),
576 timestamp: base_ts + 180,
577 attributes: HashMap::new(),
578 session_id: Some(1),
579 device_id: None,
580 ip_address: None,
581 location: None,
582 },
583 ];
584
585 let signatures = vec![FraudSignature {
586 id: 3,
587 name: "Suspicious Time Window".to_string(),
588 pattern: SignaturePattern::TimeWindow {
589 events: vec![
590 "login".to_string(),
591 "high_value_purchase".to_string(),
592 "logout".to_string(),
593 ],
594 window_secs: 300,
595 },
596 severity: 80.0,
597 active: true,
598 }];
599
600 let matches = FraudSignatureDetection::compute(&events, &signatures);
601
602 assert!(!matches.is_empty(), "Should detect time window pattern");
603 assert_eq!(matches[0].signature_id, 3);
604 }
605
606 #[test]
607 fn test_regex_detection() {
608 let mut attrs = HashMap::new();
609 attrs.insert(
610 "item_type".to_string(),
611 EventValue::String("gift_card_100".to_string()),
612 );
613
614 let events = vec![UserEvent {
615 id: 1,
616 user_id: 100,
617 event_type: "purchase".to_string(),
618 timestamp: 1700000000,
619 attributes: attrs,
620 session_id: Some(1),
621 device_id: None,
622 ip_address: None,
623 location: None,
624 }];
625
626 let signatures = vec![FraudSignature {
627 id: 4,
628 name: "Gift Card Fraud Pattern".to_string(),
629 pattern: SignaturePattern::Regex("gift_card".to_string()),
630 severity: 60.0,
631 active: true,
632 }];
633
634 let matches = FraudSignatureDetection::compute(&events, &signatures);
635
636 assert!(!matches.is_empty(), "Should detect gift card pattern");
637 assert_eq!(matches[0].signature_id, 4);
638 }
639
640 #[test]
641 fn test_inactive_signature_ignored() {
642 let events = create_login_attack_events();
643 let signatures = vec![FraudSignature {
644 id: 1,
645 name: "Rapid Login Attempts".to_string(),
646 pattern: SignaturePattern::CountThreshold {
647 event_type: "login_attempt".to_string(),
648 count: 5,
649 window_secs: 60,
650 },
651 severity: 70.0,
652 active: false, }];
654
655 let matches = FraudSignatureDetection::compute(&events, &signatures);
656
657 assert!(matches.is_empty(), "Inactive signatures should be ignored");
658 }
659
660 #[test]
661 fn test_event_attributes_match() {
662 let mut attrs = HashMap::new();
663 attrs.insert("country".to_string(), EventValue::String("XX".to_string()));
664 attrs.insert("amount".to_string(), EventValue::Number(10000.0));
665
666 let events = vec![UserEvent {
667 id: 1,
668 user_id: 100,
669 event_type: "transfer".to_string(),
670 timestamp: 1700000000,
671 attributes: attrs,
672 session_id: None,
673 device_id: None,
674 ip_address: None,
675 location: None,
676 }];
677
678 let mut required_attrs = HashMap::new();
679 required_attrs.insert("country".to_string(), EventValue::String("XX".to_string()));
680
681 let signatures = vec![FraudSignature {
682 id: 5,
683 name: "High Risk Country Transfer".to_string(),
684 pattern: SignaturePattern::EventAttributes("transfer".to_string(), required_attrs),
685 severity: 75.0,
686 active: true,
687 }];
688
689 let matches = FraudSignatureDetection::compute(&events, &signatures);
690
691 assert!(!matches.is_empty(), "Should match event attributes");
692 assert_eq!(matches[0].signature_id, 5);
693 }
694
695 #[test]
696 fn test_standard_signatures() {
697 let signatures = FraudSignatureDetection::standard_signatures();
698
699 assert!(!signatures.is_empty());
700 assert!(signatures.iter().all(|s| s.active));
701 }
702
703 #[test]
704 fn test_no_match() {
705 let events = vec![UserEvent {
706 id: 1,
707 user_id: 100,
708 event_type: "normal_activity".to_string(),
709 timestamp: 1700000000,
710 attributes: HashMap::new(),
711 session_id: Some(1),
712 device_id: None,
713 ip_address: None,
714 location: None,
715 }];
716
717 let signatures = FraudSignatureDetection::standard_signatures();
718 let matches = FraudSignatureDetection::compute(&events, &signatures);
719
720 assert!(matches.is_empty(), "Should not match normal activity");
721 }
722}