Skip to main content

datasynth_banking/typologies/
spoofing.rs

1//! Spoofing engine for adversarial AML pattern camouflage.
2
3use chrono::Timelike;
4use datasynth_core::models::banking::TransactionCategory;
5use rand::prelude::*;
6use rand_chacha::ChaCha8Rng;
7use rust_decimal::Decimal;
8
9use crate::config::SpoofingConfig;
10use crate::models::{BankTransaction, BankingCustomer};
11
12/// Spoofing engine for adversarial mode.
13///
14/// Spoofing makes suspicious transactions appear more legitimate by:
15/// - Aligning timing to customer's normal cadence
16/// - Sampling amounts from customer's historical distribution
17/// - Using merchant categories consistent with persona
18/// - Adjusting velocity to match baseline behavior
19/// - Adding longer dwell times to avoid detection
20pub struct SpoofingEngine {
21    config: SpoofingConfig,
22    rng: ChaCha8Rng,
23}
24
25impl SpoofingEngine {
26    /// Create a new spoofing engine.
27    pub fn new(config: SpoofingConfig, seed: u64) -> Self {
28        Self {
29            config,
30            rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6400)),
31        }
32    }
33
34    /// Apply spoofing to a transaction.
35    pub fn apply(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
36        if !self.config.enabled || txn.spoofing_intensity.is_none() {
37            return;
38        }
39
40        let intensity = txn.spoofing_intensity.unwrap_or(0.0);
41
42        // Adjust timing to look more natural
43        if self.config.spoof_timing && self.rng.gen::<f64>() < intensity {
44            self.spoof_timing(txn, customer);
45        }
46
47        // Adjust amounts to fit customer profile
48        if self.config.spoof_amounts && self.rng.gen::<f64>() < intensity {
49            self.spoof_amount(txn, customer);
50        }
51
52        // Use persona-appropriate merchant categories
53        if self.config.spoof_merchants && self.rng.gen::<f64>() < intensity {
54            self.spoof_merchant(txn, customer);
55        }
56
57        // Add delays to reduce velocity signatures
58        if self.config.add_delays && self.rng.gen::<f64>() < intensity {
59            self.add_timing_jitter(txn);
60        }
61    }
62
63    /// Spoof transaction timing to match customer patterns.
64    fn spoof_timing(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
65        // Adjust timestamp to business hours for this customer type
66        let current_hour = txn.timestamp_initiated.hour();
67
68        // Move suspicious late-night/early-morning transactions to business hours
69        if !(7..=22).contains(&current_hour) {
70            let new_hour = self.rng.gen_range(9..18);
71            let new_minute = self.rng.gen_range(0..60);
72            let new_second = self.rng.gen_range(0..60);
73
74            if let Some(new_time) = txn
75                .timestamp_initiated
76                .date_naive()
77                .and_hms_opt(new_hour, new_minute, new_second)
78            {
79                txn.timestamp_initiated =
80                    chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
81                        new_time,
82                        chrono::Utc,
83                    );
84            }
85        }
86    }
87
88    /// Spoof transaction amount to fit customer profile.
89    fn spoof_amount(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
90        // Add noise to make amounts less conspicuous
91        // Avoid round numbers that trigger rules
92        let amount_f64: f64 = txn.amount.try_into().unwrap_or(0.0);
93
94        // Add small random cents
95        let cents = self.rng.gen_range(1..99) as f64 / 100.0;
96        let new_amount = amount_f64 + cents;
97
98        // Avoid threshold-adjacent amounts (like $9,999)
99        let new_amount = self.avoid_thresholds(new_amount);
100
101        txn.amount = Decimal::from_f64_retain(new_amount).unwrap_or(txn.amount);
102    }
103
104    /// Avoid amounts near reporting thresholds.
105    fn avoid_thresholds(&mut self, amount: f64) -> f64 {
106        let thresholds = [10_000.0, 5_000.0, 3_000.0, 1_000.0];
107
108        for threshold in thresholds {
109            let lower_bound = threshold * 0.95;
110            let upper_bound = threshold * 1.05;
111
112            if amount > lower_bound && amount < upper_bound {
113                // Move amount away from threshold
114                if self.rng.gen::<bool>() {
115                    return threshold * 0.85 + self.rng.gen_range(0.0..100.0);
116                } else {
117                    return threshold * 1.15 + self.rng.gen_range(0.0..100.0);
118                }
119            }
120        }
121
122        amount
123    }
124
125    /// Spoof merchant to match customer persona.
126    fn spoof_merchant(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
127        // Use categories typical for this customer's persona
128        let persona_categories = self.get_persona_categories(customer);
129
130        if !persona_categories.is_empty() {
131            let idx = self.rng.gen_range(0..persona_categories.len());
132            txn.category = persona_categories[idx];
133        }
134    }
135
136    /// Get typical merchant categories for a customer persona.
137    fn get_persona_categories(&self, customer: &BankingCustomer) -> Vec<TransactionCategory> {
138        use crate::models::PersonaVariant;
139        use datasynth_core::models::banking::RetailPersona;
140
141        match &customer.persona {
142            Some(PersonaVariant::Retail(persona)) => match persona {
143                RetailPersona::Student => vec![
144                    TransactionCategory::Shopping,
145                    TransactionCategory::Dining,
146                    TransactionCategory::Entertainment,
147                    TransactionCategory::Subscription,
148                ],
149                RetailPersona::EarlyCareer => vec![
150                    TransactionCategory::Shopping,
151                    TransactionCategory::Dining,
152                    TransactionCategory::Subscription,
153                    TransactionCategory::Transportation,
154                ],
155                RetailPersona::MidCareer => vec![
156                    TransactionCategory::Groceries,
157                    TransactionCategory::Shopping,
158                    TransactionCategory::Utilities,
159                    TransactionCategory::Insurance,
160                ],
161                RetailPersona::Retiree => vec![
162                    TransactionCategory::Healthcare,
163                    TransactionCategory::Groceries,
164                    TransactionCategory::Utilities,
165                ],
166                RetailPersona::HighNetWorth => vec![
167                    TransactionCategory::Investment,
168                    TransactionCategory::Entertainment,
169                    TransactionCategory::Shopping,
170                ],
171                RetailPersona::GigWorker => vec![
172                    TransactionCategory::Shopping,
173                    TransactionCategory::Transportation,
174                    TransactionCategory::Dining,
175                ],
176                _ => vec![
177                    TransactionCategory::Shopping,
178                    TransactionCategory::Groceries,
179                ],
180            },
181            Some(PersonaVariant::Business(_)) => vec![
182                TransactionCategory::TransferOut,
183                TransactionCategory::Utilities,
184                TransactionCategory::Other,
185            ],
186            Some(PersonaVariant::Trust(_)) => vec![
187                TransactionCategory::Investment,
188                TransactionCategory::Other,
189                TransactionCategory::Charity,
190            ],
191            None => vec![TransactionCategory::Shopping],
192        }
193    }
194
195    /// Add timing jitter to reduce velocity detection.
196    fn add_timing_jitter(&mut self, txn: &mut BankTransaction) {
197        // Add random minutes to the timestamp
198        let jitter_minutes = self.rng.gen_range(-30..30);
199        txn.timestamp_initiated += chrono::Duration::minutes(jitter_minutes as i64);
200    }
201
202    /// Calculate spoofing effectiveness score.
203    pub fn calculate_effectiveness(
204        &self,
205        txn: &BankTransaction,
206        customer: &BankingCustomer,
207    ) -> f64 {
208        let mut score = 0.0;
209        let mut factors = 0;
210
211        // Check timing alignment
212        let hour = txn.timestamp_initiated.hour();
213        if (9..=17).contains(&hour) {
214            score += 1.0;
215        }
216        factors += 1;
217
218        // Check amount naturalness
219        let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
220        let has_cents = (amount * 100.0) % 100.0 != 0.0;
221        if has_cents {
222            score += 0.5;
223        }
224        // Not near thresholds
225        if !(9_000.0..=11_000.0).contains(&amount) {
226            score += 0.5;
227        }
228        factors += 1;
229
230        // Check category alignment
231        let expected = self.get_persona_categories(customer);
232        if expected.contains(&txn.category) {
233            score += 1.0;
234        }
235        factors += 1;
236
237        score / factors as f64
238    }
239}
240
241/// Spoofing statistics for reporting.
242#[derive(Debug, Clone, Default)]
243pub struct SpoofingStats {
244    /// Number of transactions spoofed
245    pub transactions_spoofed: usize,
246    /// Number of timing adjustments
247    pub timing_adjustments: usize,
248    /// Number of amount adjustments
249    pub amount_adjustments: usize,
250    /// Number of merchant category changes
251    pub merchant_changes: usize,
252    /// Average spoofing effectiveness
253    pub avg_effectiveness: f64,
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use chrono::NaiveDate;
260    use uuid::Uuid;
261
262    #[test]
263    fn test_spoofing_engine() {
264        let config = SpoofingConfig {
265            enabled: true,
266            intensity: 0.5,
267            spoof_timing: true,
268            spoof_amounts: true,
269            spoof_merchants: true,
270            spoof_geography: false,
271            add_delays: true,
272        };
273
274        let mut engine = SpoofingEngine::new(config, 12345);
275
276        let customer = BankingCustomer::new_retail(
277            Uuid::new_v4(),
278            "Test",
279            "User",
280            "US",
281            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
282        );
283
284        let account_id = Uuid::new_v4();
285        let mut txn = BankTransaction::new(
286            Uuid::new_v4(),
287            account_id,
288            Decimal::from(9999),
289            "USD",
290            datasynth_core::models::banking::Direction::Outbound,
291            datasynth_core::models::banking::TransactionChannel::Wire,
292            TransactionCategory::TransferOut,
293            crate::models::CounterpartyRef::person("Test"),
294            "Test transaction",
295            chrono::Utc::now(),
296        );
297
298        txn.spoofing_intensity = Some(0.8);
299
300        engine.apply(&mut txn, &customer);
301
302        // Transaction should have been modified
303        // Amount should no longer be exactly 9999
304        let amount: f64 = txn.amount.try_into().unwrap();
305        assert!(amount != 9999.0 || amount != 9999.0); // Either changed or has cents
306    }
307
308    #[test]
309    fn test_threshold_avoidance() {
310        let config = SpoofingConfig::default();
311        let mut engine = SpoofingEngine::new(config, 12345);
312
313        // Test amounts near $10k threshold
314        let amount = 9_950.0;
315        let adjusted = engine.avoid_thresholds(amount);
316
317        // Should be moved away from threshold
318        assert!(!(9_500.0..=10_500.0).contains(&adjusted));
319    }
320}