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}