Skip to main content

stygian_browser/integrity_canary/
mod.rs

1//! JavaScript integrity trap canary probes.
2//!
3//! ## What is a "JavaScript integrity trap"?
4//!
5//! Modern anti-bot vendors (`Cloudflare`, `DataDome`, `PerimeterX`,
6//! Akamai Bot Manager, `Kasada`) ship detection scripts that look for
7//! artefacts left by **patched** browser surfaces — places where a
8//! stealth framework rewrote a native prototype, getter, or accessor
9//! and the patch is detectable from the JavaScript side. Classic
10//! examples:
11//!
12//! - `Object.getOwnPropertyDescriptor(Navigator.prototype, "webdriver")`
13//!   returning a **data property** instead of a native **accessor**.
14//! - `Function.prototype.toString` showing patch source code (e.g.
15//!   `"function webdriver() { [native code] }"`) when applied to a
16//!   patched prototype method.
17//! - `Performance.now` returning suspiciously round / quantized
18//!   values that betray deterministic jitter injection.
19//!
20//! The traps come in two shapes:
21//!
22//! - **Suspected** — the surface shape is unusual but the signal is
23//!   ambiguous (e.g. a non-`native code` `toString` on a polyfill
24//!   that the user's browser already shipped).
25//! - **Confirmed** — the surface shape is only achievable via a
26//!   stealth framework patch on a real browser (e.g. `webdriver`
27//!   is a **data** property on `Navigator.prototype`).
28//!
29//! ## What this module provides
30//!
31//! 1. A stable [`IntegrityProbe`] catalogue with weighted risk
32//!    contributions and per-probe mitigation hints (see
33//!    [`probes::all_probes`]).
34//! 2. A pure-Rust scoring pipeline ([`report::IntegrityRiskScore`])
35//!    that turns a set of [`probes::ProbeFinding`] records into an
36//!    aggregate score and a documented **Suspected** vs **Confirmed**
37//!    classification.
38//! 3. A trend-detection seam ([`trend::CanaryTrendObservation`])
39//!    that future canary infrastructure (T84) can subscribe to
40//!    without modifying the probe set.
41//!
42//! ## Probe catalogue
43//!
44//! The default probe set (see [`probes::all_probes`]) covers eight
45//! surfaces:
46//!
47//! | Probe | Default weight | What it checks |
48//! |---|---|---|
49//! | [`probes::IntegrityProbeId::WebDriverDescriptorNative`] | 0.20 | `Navigator.prototype.webdriver` accessor shape |
50//! | [`probes::IntegrityProbeId::FunctionToStringNative`]      | 0.18 | `Function.prototype.toString` reports `[native code]` for patched natives |
51//! | [`probes::IntegrityProbeId::ErrorToStringNative`]        | 0.08 | `(function(){}).toString()` reports `[native code]` |
52//! | [`probes::IntegrityProbeId::IntlDateTimeFormatNative`]   | 0.10 | `Intl.DateTimeFormat.prototype.format` is native |
53//! | [`probes::IntegrityProbeId::RegExpTestNative`]          | 0.08 | `RegExp.prototype.test` is native |
54//! | [`probes::IntegrityProbeId::CanvasGetImageDataNative`]  | 0.10 | `CanvasRenderingContext2D.prototype.getImageData` is native |
55//! | [`probes::IntegrityProbeId::PerformanceNowResolution`]   | 0.14 | `performance.now()` resolution is plausible (not quantized) |
56//! | [`probes::IntegrityProbeId::ProxyTrapObservable`]       | 0.12 | `Proxy` traps on patched natives do not leak surface state |
57//!
58//! ## Feature flag
59//!
60//! This module is **default-on** and is always compiled as part of
61//! the `stygian-browser` crate. The probe set and scoring pipeline
62//! are pure Rust with **no I/O** so they are safely callable in
63//! deterministic tests without booting Chrome.
64//!
65//! ## Integration with the existing diagnostic payload
66//!
67//! The canary report attaches additively to
68//! [`crate::diagnostic::DiagnosticReport`] via
69//! [`crate::diagnostic::DiagnosticReport::with_integrity_canary`]
70//! (added in this task) so downstream automation can consume the
71//! finding set without breaking the legacy schema.
72//!
73//! ## Reuse of the canary trend pipeline (T84)
74//!
75//! T84 will add a stealth canary hard-gate. This module exposes
76//! [`trend::CanaryTrendObservation`] as the **stable seam** that
77//! future canary infrastructure can consume without changing probe
78//! definitions: each observation carries the normalized risk score
79//! and a deterministic `signature` string so two reports with the
80//! same findings produce byte-identical trend entries.
81//!
82//! # Example
83//!
84//! ```
85//! use stygian_browser::integrity_canary::{
86//!     IntegrityCanaryReport, IntegrityProbe, IntegrityRiskClassification,
87//! };
88//!
89//! // Simulate a probe set where two probes fired with confirmed traps.
90//! let finding_a = IntegrityProbe::confirmed_finding(
91//!     "webdriver_descriptor_native",
92//!     0.20,
93//!     "Navigator.prototype.webdriver is a data property (should be an accessor)",
94//! );
95//! let finding_b = IntegrityProbe::confirmed_finding(
96//!     "performance_now_resolution",
97//!     0.14,
98//!     "performance.now() values are quantized to 0.1 ms (timing-noise injection)",
99//! );
100//!
101//! let report = IntegrityCanaryReport::from_findings(vec![finding_a, finding_b]);
102//! assert!(report.score.value() > 0.0);
103//! assert!(matches!(
104//!     report.score.classification(),
105//!     IntegrityRiskClassification::Confirmed | IntegrityRiskClassification::Suspected
106//! ));
107//! assert_eq!(report.findings.len(), 2);
108//! ```
109
110mod probes;
111mod report;
112mod trend;
113
114pub use probes::{
115    IntegrityProbe, IntegrityProbeId, IntegrityProbeOutcome, ProbeFinding, all_probes, probe_by_id,
116};
117pub use report::{
118    IntegrityCanaryPolicy, IntegrityCanaryReport, IntegrityRiskClassification, IntegrityRiskScore,
119    RISK_CONFIRMED_THRESHOLD_DEFAULT, RISK_SUSPECTED_THRESHOLD_DEFAULT,
120};
121pub use trend::{CanaryTrendObservation, TrendSeverity};
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn module_exports_are_reachable_from_crate_root() {
129        // The crate-root re-exports below would fail to compile if
130        // the module structure diverges from the public API contract.
131        let _report = IntegrityCanaryReport::from_findings(Vec::new());
132        let _probes = all_probes();
133        let _policy = IntegrityCanaryPolicy::default();
134        let _score = IntegrityRiskScore::clean();
135    }
136
137    #[test]
138    fn default_thresholds_are_documented_values() {
139        assert!(
140            (RISK_SUSPECTED_THRESHOLD_DEFAULT - 0.30).abs() < 1e-9,
141            "suspected threshold must be 0.30 by default, got: {RISK_SUSPECTED_THRESHOLD_DEFAULT}"
142        );
143        assert!(
144            (RISK_CONFIRMED_THRESHOLD_DEFAULT - 0.65).abs() < 1e-9,
145            "confirmed threshold must be 0.65 by default, got: {RISK_CONFIRMED_THRESHOLD_DEFAULT}"
146        );
147    }
148}