Skip to main content

session_guard/
risk.rs

1//! Scores the current request against a session's baseline.
2
3use chrono::{DateTime, TimeDelta, Utc};
4
5use crate::{
6    checks::{Check, Checks, Decision, Thresholds},
7    fingerprint::{Baseline, Fingerprint, anonymity_change, os_compatible},
8};
9
10/// The checks that fired for a request and their summed weight.
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Assessment {
14    /// Sum of the weights of `signals`.
15    pub score: u32,
16    /// Enabled checks that fired, in [`Check::ALL`] order.
17    pub signals: Vec<Check>,
18}
19
20impl Assessment {
21    /// Maps the score onto a decision.
22    pub fn decision(&self, thresholds: &Thresholds) -> Decision {
23        thresholds.decide(self.score)
24    }
25
26    /// Adds a signal the fingerprint comparison cannot see, if `checks` enables it, keeping
27    /// [`Check::ALL`] order.
28    pub(crate) fn add(&mut self, check: Check, checks: &Checks) {
29        if let Some(weight) = checks.weight_of(check)
30            && !self.signals.contains(&check)
31        {
32            let at = self.signals.partition_point(|c| *c < check);
33            self.signals.insert(at, check);
34            self.score = self.score.saturating_add(weight);
35        }
36    }
37}
38
39/// Compares `current` against `baseline`.
40///
41/// A value the baseline knows but the request lacks counts as a change: stripping headers
42/// never lowers the score. Values the baseline never had are not compared.
43pub fn assess(
44    baseline: &Baseline,
45    current: &Fingerprint,
46    now: DateTime<Utc>,
47    ping_pong_window: TimeDelta,
48    checks: &Checks,
49) -> Assessment {
50    let known = &baseline.fingerprint;
51    let mut fired = Vec::with_capacity(4);
52
53    if let (Some(a), Some(b)) = (&known.os, &current.os)
54        && !os_compatible(a, b)
55    {
56        fired.push(Check::OsChanged);
57    }
58
59    if let (Some(a), Some(b)) = (&known.browser, &current.browser) {
60        if a != b {
61            fired.push(Check::BrowserChanged);
62        } else if let (Some(a), Some(b)) = (known.browser_major, current.browser_major)
63            && b < a
64        {
65            fired.push(Check::BrowserDowngraded);
66        }
67    }
68
69    if lost(known.browser.as_ref(), current.browser.as_ref()) || lost(known.os.as_ref(), current.os.as_ref()) {
70        fired.push(Check::UserAgentMissing);
71    }
72
73    if let (Some(a), Some(b)) = (&known.ch_platform, &current.ch_platform)
74        && !os_compatible(a, b)
75    {
76        fired.push(Check::PlatformHintChanged);
77    }
78
79    if lost(known.ch_platform.as_ref(), current.ch_platform.as_ref())
80        || lost(known.ch_mobile.as_ref(), current.ch_mobile.as_ref())
81    {
82        fired.push(Check::ClientHintsMissing);
83    }
84
85    if matches!((known.ch_mobile, current.ch_mobile), (Some(a), Some(b)) if a != b) {
86        fired.push(Check::MobileHintChanged);
87    }
88
89    if let (Some(platform), Some(os)) = (&current.ch_platform, &current.os)
90        && !os_compatible(platform, os)
91    {
92        fired.push(Check::InconsistentHints);
93    }
94
95    if known.ip_prefix != current.ip_prefix {
96        if differs(known.country.as_ref(), current.country.as_ref()) {
97            fired.push(Check::CountryChanged);
98        } else if differs(known.asn.as_ref(), current.asn.as_ref()) {
99            fired.push(Check::AsnChanged);
100        } else {
101            fired.push(Check::IpPrefixChanged);
102        }
103
104        let returned = baseline.previous_ip_prefix.as_deref() == Some(current.ip_prefix.as_str());
105        let recent = baseline
106            .ip_changed_at
107            .is_some_and(|at| now.signed_duration_since(at) <= ping_pong_window);
108        if returned && recent {
109            fired.push(Check::IpPingPong);
110        }
111
112        // Returning to the previous network is no news: the client was trusted there.
113        if !returned
114            && let (Some(was), Some(now)) = (known.anonymity, current.anonymity)
115            && let Some(check) = anonymity_change(was, now)
116        {
117            fired.push(check);
118        }
119    }
120
121    let version_changed = matches!((known.browser_major, current.browser_major), (Some(a), Some(b)) if a != b);
122    if !version_changed && differs(known.tls.as_ref(), current.tls.as_ref()) {
123        fired.push(Check::TlsChanged);
124    }
125
126    if differs(known.accept_language.as_ref(), current.accept_language.as_ref()) {
127        fired.push(Check::AcceptLanguageChanged);
128    }
129
130    let mut signals = Vec::with_capacity(fired.len());
131    let mut score = 0u32;
132    for check in Check::ALL {
133        if fired.contains(&check)
134            && let Some(weight) = checks.weight_of(check)
135        {
136            signals.push(check);
137            score = score.saturating_add(weight);
138        }
139    }
140    Assessment { score, signals }
141}
142
143/// Known and now different, or known and now missing.
144fn differs<T: PartialEq>(known: Option<&T>, current: Option<&T>) -> bool {
145    match (known, current) {
146        (Some(a), Some(b)) => a != b,
147        (Some(_), None) => true,
148        (None, _) => false,
149    }
150}
151
152fn lost<T>(known: Option<&T>, current: Option<&T>) -> bool {
153    known.is_some() && current.is_none()
154}
155
156#[cfg(test)]
157mod tests {
158    use proptest::prelude::*;
159
160    use super::*;
161    use crate::intel::Anonymity;
162
163    fn chrome_on_windows() -> Fingerprint {
164        Fingerprint {
165            ip_prefix: "203.0.113.0/24".to_owned(),
166            country: Some("BE".to_owned()),
167            asn: Some(5432),
168            browser: Some("Chrome".to_owned()),
169            browser_major: Some(141),
170            os: Some("Windows".to_owned()),
171            ch_platform: Some("Windows".to_owned()),
172            ch_mobile: Some(false),
173            accept_language: Some("nl-be,nl,en".to_owned()),
174            tls: Some("t13d1516h2_8daaf6152771_02713d6af862".to_owned()),
175            anonymity: Some(Anonymity::Direct),
176        }
177    }
178
179    fn via(anonymity: Anonymity) -> Fingerprint {
180        let mut fp = chrome_on_windows();
181        fp.ip_prefix = "198.51.100.0/24".to_owned();
182        fp.asn = Some(9009);
183        fp.anonymity = Some(anonymity);
184        fp
185    }
186
187    fn assess_now(baseline: &Baseline, current: &Fingerprint) -> Assessment {
188        assess(baseline, current, Utc::now(), TimeDelta::minutes(5), &Checks::default())
189    }
190
191    fn decide(current: &Fingerprint) -> (Decision, Vec<Check>) {
192        let a = assess_now(&Baseline::new(chrome_on_windows()), current);
193        (a.decision(&Thresholds::default()), a.signals)
194    }
195
196    #[test]
197    fn added_signals_keep_check_order_and_respect_weights() {
198        let mut current = chrome_on_windows();
199        current.ip_prefix = "198.51.100.0/24".to_owned();
200        current.asn = Some(6848);
201        current.accept_language = Some("en".to_owned());
202        let mut a = assess_now(&Baseline::new(chrome_on_windows()), &current);
203        a.add(Check::OrphanedToken, &Checks::default());
204        assert_eq!(
205            a.signals,
206            vec![Check::OrphanedToken, Check::AsnChanged, Check::AcceptLanguageChanged]
207        );
208        assert_eq!(a.score, 25 + 20 + 15);
209
210        let mut a = Assessment::default();
211        a.add(Check::OrphanedToken, &Checks::none());
212        assert_eq!(a, Assessment::default());
213    }
214
215    #[test]
216    fn identical_request_is_allowed() {
217        assert_eq!(decide(&chrome_on_windows()), (Decision::Allow, vec![]));
218    }
219
220    // Legitimate users: none of these may step up or revoke.
221
222    #[test]
223    fn network_switch_within_same_provider_is_allowed() {
224        let mut current = chrome_on_windows();
225        current.ip_prefix = "203.0.200.0/24".to_owned();
226        assert_eq!(decide(&current), (Decision::Allow, vec![Check::IpPrefixChanged]));
227    }
228
229    #[test]
230    fn wifi_to_mobile_data_is_allowed() {
231        let mut current = chrome_on_windows();
232        current.ip_prefix = "198.51.100.0/24".to_owned();
233        current.asn = Some(6848);
234        assert_eq!(decide(&current), (Decision::Allow, vec![Check::AsnChanged]));
235    }
236
237    #[test]
238    fn browser_update_is_allowed_even_though_tls_changes() {
239        let mut current = chrome_on_windows();
240        current.browser_major = Some(142);
241        current.tls = Some("t13d1517h2_8daaf6152771_b0da82dd1658".to_owned());
242        assert_eq!(decide(&current), (Decision::Allow, vec![]));
243    }
244
245    #[test]
246    fn language_tweak_is_allowed() {
247        let mut current = chrome_on_windows();
248        current.accept_language = Some("en".to_owned());
249        assert_eq!(decide(&current).0, Decision::Allow);
250    }
251
252    #[test]
253    fn android_desktop_mode_toggle_steps_up_not_revokes() {
254        let mut fp = chrome_on_windows();
255        fp.os = Some("Android".to_owned());
256        fp.ch_platform = Some("Android".to_owned());
257        fp.ch_mobile = Some(true);
258        let mut current = fp.clone();
259        current.os = Some("Linux".to_owned());
260        current.ch_mobile = Some(false);
261        let a = assess_now(&Baseline::new(fp), &current);
262        assert_eq!(a.signals, vec![Check::MobileHintChanged]);
263        assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
264    }
265
266    #[test]
267    fn travel_abroad_steps_up_not_revokes() {
268        let mut current = chrome_on_windows();
269        current.ip_prefix = "192.0.2.0/24".to_owned();
270        current.country = Some("FR".to_owned());
271        current.asn = Some(3215);
272        assert_eq!(decide(&current), (Decision::StepUp, vec![Check::CountryChanged]));
273    }
274
275    #[test]
276    fn baseline_without_hints_ignores_hints() {
277        let mut firefox = chrome_on_windows();
278        firefox.browser = Some("Firefox".to_owned());
279        firefox.ch_platform = None;
280        firefox.ch_mobile = None;
281        let a = assess_now(&Baseline::new(firefox.clone()), &firefox);
282        assert_eq!(a.score, 0);
283    }
284
285    // Theft.
286
287    #[test]
288    fn different_operating_system_is_revoked() {
289        let mut current = chrome_on_windows();
290        current.os = Some("macOS".to_owned());
291        current.ch_platform = Some("macOS".to_owned());
292        assert_eq!(decide(&current).0, Decision::Revoke);
293    }
294
295    #[test]
296    fn different_browser_is_revoked() {
297        let mut current = chrome_on_windows();
298        current.browser = Some("Firefox".to_owned());
299        current.ch_platform = None;
300        current.ch_mobile = None;
301        assert_eq!(decide(&current).0, Decision::Revoke);
302    }
303
304    #[test]
305    fn empty_user_agent_cannot_bypass_checks() {
306        let mut current = chrome_on_windows();
307        current.browser = None;
308        current.browser_major = None;
309        current.os = None;
310        current.ch_platform = None;
311        current.ch_mobile = None;
312        let (decision, signals) = decide(&current);
313        assert!(signals.contains(&Check::UserAgentMissing));
314        assert!(signals.contains(&Check::ClientHintsMissing));
315        assert_eq!(decision, Decision::Revoke);
316    }
317
318    #[test]
319    fn dropping_only_the_mobile_hint_is_noticed() {
320        let mut current = chrome_on_windows();
321        current.ch_mobile = None;
322        assert_eq!(decide(&current).1, vec![Check::ClientHintsMissing]);
323    }
324
325    #[test]
326    fn stripping_soft_headers_never_lowers_the_score() {
327        let mut current = chrome_on_windows();
328        current.accept_language = None;
329        current.tls = None;
330        assert_eq!(
331            decide(&current).1,
332            vec![Check::TlsChanged, Check::AcceptLanguageChanged]
333        );
334
335        let mut current = chrome_on_windows();
336        current.ip_prefix = "198.51.100.0/24".to_owned();
337        current.country = None;
338        assert_eq!(decide(&current).1, vec![Check::CountryChanged]);
339    }
340
341    #[test]
342    fn copied_user_agent_from_script_in_same_country_is_revoked() {
343        let mut current = chrome_on_windows();
344        current.ip_prefix = "198.51.100.0/24".to_owned();
345        current.asn = Some(9999);
346        current.ch_platform = None;
347        current.ch_mobile = None;
348        current.tls = Some("t13d311200_e8f1e7e78f70_d41ae481755e".to_owned());
349        current.accept_language = None;
350        assert_eq!(decide(&current).0, Decision::Revoke);
351    }
352
353    #[test]
354    fn spoofed_ua_contradicting_client_hints_is_flagged() {
355        let mut current = chrome_on_windows();
356        current.ch_platform = Some("Linux".to_owned());
357        let (decision, signals) = decide(&current);
358        assert_eq!(signals, vec![Check::PlatformHintChanged, Check::InconsistentHints]);
359        assert_eq!(decision, Decision::Revoke);
360    }
361
362    #[test]
363    fn alternating_between_two_networks_adds_ping_pong() {
364        let now = Utc::now();
365        let mut baseline = Baseline::new(chrome_on_windows());
366        let mut elsewhere = chrome_on_windows();
367        elsewhere.ip_prefix = "198.51.100.0/24".to_owned();
368        elsewhere.asn = Some(9999);
369        baseline.absorb(&elsewhere, now, &Checks::default());
370
371        let back = chrome_on_windows();
372        let soon = assess(
373            &baseline,
374            &back,
375            now + TimeDelta::seconds(20),
376            TimeDelta::minutes(5),
377            &Checks::default(),
378        );
379        assert_eq!(soon.signals, vec![Check::IpPingPong, Check::AsnChanged]);
380        // Bouncing between two ASNs within minutes looks like two parties sharing one cookie.
381        assert_eq!(soon.decision(&Thresholds::default()), Decision::StepUp);
382
383        let later = assess(
384            &baseline,
385            &back,
386            now + TimeDelta::minutes(10),
387            TimeDelta::minutes(5),
388            &Checks::default(),
389        );
390        assert_eq!(later.signals, vec![Check::AsnChanged]);
391    }
392
393    #[test]
394    fn switching_on_a_vpn_steps_up() {
395        for anonymizer in [Anonymity::Vpn, Anonymity::HostingProvider, Anonymity::ResidentialProxy] {
396            assert_eq!(
397                decide(&via(anonymizer)),
398                (Decision::StepUp, vec![Check::AnonymizerEntered, Check::AsnChanged]),
399                "{anonymizer:?}"
400            );
401        }
402        let mut abroad = via(Anonymity::Vpn);
403        abroad.country = Some("NL".to_owned());
404        assert_eq!(
405            decide(&abroad),
406            (Decision::StepUp, vec![Check::CountryChanged, Check::AnonymizerEntered])
407        );
408    }
409
410    #[test]
411    fn tor_steps_up_and_tor_abroad_revokes() {
412        for anonymizer in [Anonymity::TorExitNode, Anonymity::PublicProxy] {
413            assert_eq!(
414                decide(&via(anonymizer)),
415                (Decision::StepUp, vec![Check::TorOrProxyEntered, Check::AsnChanged]),
416                "{anonymizer:?}"
417            );
418        }
419        let mut abroad = via(Anonymity::TorExitNode);
420        abroad.country = Some("DE".to_owned());
421        assert_eq!(
422            decide(&abroad),
423            (Decision::Revoke, vec![Check::TorOrProxyEntered, Check::CountryChanged])
424        );
425    }
426
427    #[test]
428    fn anonymity_only_scores_moves_onto_a_worse_network() {
429        let assess_from = |was: Option<Anonymity>, now: Option<Anonymity>| {
430            let mut known = via(Anonymity::Direct);
431            known.anonymity = was;
432            let mut current = chrome_on_windows();
433            current.anonymity = now;
434            assess_now(&Baseline::new(known), &current).signals
435        };
436        let vpn = Some(Anonymity::Vpn);
437        let tor = Some(Anonymity::TorExitNode);
438        let direct = Some(Anonymity::Direct);
439        assert_eq!(assess_from(vpn, direct), vec![Check::AsnChanged], "leaving a VPN");
440        assert_eq!(
441            assess_from(vpn, Some(Anonymity::HostingProvider)),
442            vec![Check::AsnChanged]
443        );
444        assert_eq!(assess_from(tor, vpn), vec![Check::AsnChanged], "Tor to VPN");
445        assert_eq!(assess_from(tor, Some(Anonymity::PublicProxy)), vec![Check::AsnChanged]);
446        assert_eq!(assess_from(vpn, tor), vec![Check::TorOrProxyEntered, Check::AsnChanged]);
447        assert_eq!(assess_from(None, tor), vec![Check::AsnChanged], "unknown baseline");
448        assert_eq!(assess_from(direct, None), vec![Check::AsnChanged], "lookup failed");
449    }
450
451    #[test]
452    fn anonymity_needs_a_network_move() {
453        let mut current = chrome_on_windows();
454        current.anonymity = Some(Anonymity::Vpn);
455        assert_eq!(decide(&current), (Decision::Allow, vec![]));
456    }
457
458    #[test]
459    fn toggling_a_vpn_back_on_does_not_revoke() {
460        let now = Utc::now();
461        let mut baseline = Baseline::new(chrome_on_windows());
462        baseline.absorb(&via(Anonymity::Vpn), now, &Checks::default());
463        baseline.absorb(&chrome_on_windows(), now + TimeDelta::seconds(10), &Checks::default());
464
465        let a = assess(
466            &baseline,
467            &via(Anonymity::Vpn),
468            now + TimeDelta::seconds(20),
469            TimeDelta::minutes(5),
470            &Checks::default(),
471        );
472        // The client was already trusted on this VPN: only the bounce counts.
473        assert_eq!(a.signals, vec![Check::IpPingPong, Check::AsnChanged]);
474        assert_eq!(a.decision(&Thresholds::default()), Decision::StepUp);
475    }
476
477    #[test]
478    fn downgrade_is_flagged() {
479        let mut current = chrome_on_windows();
480        current.browser_major = Some(120);
481        assert_eq!(decide(&current), (Decision::StepUp, vec![Check::BrowserDowngraded]));
482    }
483
484    #[test]
485    fn disabled_checks_and_custom_weights() {
486        let mut current = chrome_on_windows();
487        current.os = Some("macOS".to_owned());
488        let checks = Checks::default()
489            .disable(Check::OsChanged)
490            .weight(Check::InconsistentHints, 5);
491        let a = assess(
492            &Baseline::new(chrome_on_windows()),
493            &current,
494            Utc::now(),
495            TimeDelta::minutes(5),
496            &checks,
497        );
498        assert_eq!(a.signals, vec![Check::InconsistentHints]);
499        assert_eq!(a.score, 5);
500        let none = assess(
501            &Baseline::new(chrome_on_windows()),
502            &current,
503            Utc::now(),
504            TimeDelta::minutes(5),
505            &Checks::none(),
506        );
507        assert_eq!(none, Assessment::default());
508    }
509
510    /// Changes legitimate users make routinely, one at a time.
511    fn routine_change() -> impl Strategy<Value = (u8, u32)> {
512        (0u8..9, any::<u32>())
513    }
514
515    fn apply_routine(fp: &mut Fingerprint, kind: u8, n: u32) {
516        match kind {
517            0 => fp.ip_prefix = format!("198.51.{}.0/24", n % 256),
518            1 => {
519                fp.ip_prefix = format!("100.64.{}.0/24", n % 256);
520                fp.asn = Some(n);
521            }
522            2 => {
523                fp.ip_prefix = format!("192.0.{}.0/24", n % 256);
524                fp.country = Some(["FR", "DE", "US", "JP"][(n % 4) as usize].to_owned());
525                fp.asn = Some(n);
526            }
527            3 => {
528                fp.browser_major = fp.browser_major.map(|m| m + 1 + n % 5);
529                fp.tls = Some(format!("tls-{n}"));
530            }
531            4 => fp.accept_language = Some(["en", "fr,en", "de-de,de"][(n % 3) as usize].to_owned()),
532            5 => fp.tls = Some(format!("tls-{n}")),
533            6 => fp.ch_mobile = fp.ch_mobile.map(|m| !m),
534            7 => {
535                fp.ip_prefix = format!("185.{}.0.0/24", n % 256);
536                fp.country = Some(["BE", "NL", "CH", "US"][(n % 4) as usize].to_owned());
537                fp.asn = Some(n);
538                fp.anonymity = Some(Anonymity::Vpn);
539            }
540            _ => fp.os = Some("Linux".to_owned()),
541        }
542    }
543
544    proptest! {
545        #[test]
546        fn no_single_routine_change_revokes(
547            (kind, n) in routine_change(),
548            android in any::<bool>(),
549            previous in any::<bool>(),
550            secs in 0i64..600,
551        ) {
552            let mut fp = chrome_on_windows();
553            if android {
554                fp.os = Some("Android".into());
555                fp.ch_platform = Some("Android".into());
556                fp.ch_mobile = Some(true);
557            }
558            if !android && kind == 8 {
559                // Linux is only a routine change for Android devices.
560                return Ok(());
561            }
562            let now = Utc::now();
563            let mut baseline = Baseline::new(fp.clone());
564            let mut current = fp.clone();
565            apply_routine(&mut current, kind, n);
566            if previous {
567                baseline.previous_ip_prefix = Some(current.ip_prefix.clone());
568                baseline.ip_changed_at = Some(now - TimeDelta::seconds(secs));
569            }
570            let a = assess(&baseline, &current, now, TimeDelta::minutes(5), &Checks::default());
571            prop_assert_ne!(a.decision(&Thresholds::default()), Decision::Revoke, "{:?}", a);
572        }
573
574        #[test]
575        fn score_is_sum_of_signal_weights(
576            os in proptest::option::of("[A-Za-z]{1,8}"),
577            browser in proptest::option::of("[A-Za-z]{1,8}"),
578            major in proptest::option::of(0u32..300),
579            platform in proptest::option::of("[A-Za-z]{1,8}"),
580            mobile in proptest::option::of(any::<bool>()),
581            lang in proptest::option::of("[a-z]{2}"),
582        ) {
583            let current = Fingerprint {
584                os, browser, browser_major: major, ch_platform: platform, ch_mobile: mobile,
585                accept_language: lang, ..chrome_on_windows()
586            };
587            let a = assess(&Baseline::new(chrome_on_windows()), &current, Utc::now(), TimeDelta::minutes(5), &Checks::default());
588            let sum: u32 = a.signals.iter().map(|c| c.default_weight()).sum();
589            prop_assert_eq!(a.score, sum);
590        }
591    }
592}