Skip to main content

stygian_browser/
diagnostic.rs

1//! Stealth self-diagnostic — JavaScript detection checks.
2//!
3//! Defines a catalogue of JavaScript snippets that detect common browser-
4//! automation telltales when evaluated inside a live browser context via
5//! CDP `Runtime.evaluate`.
6//!
7//! Each check script evaluates to a JSON string:
8//!
9//! ```json
10//! { "passed": true, "details": "..." }
11//! ```
12//!
13//! # Usage
14//!
15//! 1. Iterate [`all_checks`] to get the built-in check catalogue.
16//! 2. For each [`DetectionCheck`], send `check.script` to the browser via
17//!    CDP and collect the returned JSON string.
18//! 3. Call [`DetectionCheck::parse_output`] to get a [`CheckResult`].
19//! 4. Aggregate with [`DiagnosticReport::new`].
20//!
21//! # Example
22//!
23//! ```
24//! use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
25//!
26//! // Simulate every check returning a passing result
27//! let results = all_checks()
28//!     .iter()
29//!     .map(|check| check.parse_output(r#"{"passed":true,"details":"ok"}"#))
30//!     .collect::<Vec<_>>();
31//!
32//! let report = DiagnosticReport::new(results);
33//! assert!(report.is_clean());
34//! ```
35
36use serde::{Deserialize, Serialize};
37
38// ── CheckId ───────────────────────────────────────────────────────────────────
39
40/// Stable identifier for a built-in stealth detection check.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CheckId {
44    /// `navigator.webdriver` must be `undefined` or `false`.
45    WebDriverFlag,
46    /// `window.chrome.runtime` must be present (absent in headless by default).
47    ChromeObject,
48    /// `navigator.plugins` must have at least one entry.
49    PluginCount,
50    /// `navigator.languages` must be non-empty.
51    LanguagesPresent,
52    /// Canvas `toDataURL()` must return non-trivial image data.
53    CanvasConsistency,
54    /// WebGL vendor/renderer must not contain the `SwiftShader` software-renderer marker.
55    WebGlVendor,
56    /// No automation-specific globals (`__puppeteer__`, `__playwright`, etc.) must be present.
57    AutomationGlobals,
58    /// `window.outerWidth` and `window.outerHeight` must be non-zero.
59    OuterWindowSize,
60    /// `navigator.userAgent` must not contain the `"HeadlessChrome"` substring.
61    HeadlessUserAgent,
62    /// `Notification.permission` must not be pre-granted (automation artefact).
63    NotificationPermission,
64    /// `window.matchMedia` must be a function (PX env-bitmask bit 0).
65    MatchMediaPresent,
66    /// `document.elementFromPoint` must be a function (PX env-bitmask bit 1).
67    ElementFromPointPresent,
68    /// `window.requestAnimationFrame` must be a function (PX env-bitmask bit 2).
69    RequestAnimationFramePresent,
70    /// `window.getComputedStyle` must be a function (PX env-bitmask bit 3).
71    GetComputedStylePresent,
72    /// `CSS.supports` must exist and be callable (PX env-bitmask bit 4).
73    CssSupportsPresent,
74    /// `navigator.sendBeacon` must be a function (PX env-bitmask bit 5).
75    SendBeaconPresent,
76    /// `document.execCommand` must be a function (PX env-bitmask bit 6).
77    ExecCommandPresent,
78    /// `process.versions.node` must be absent — not a Node.js environment (PX env-bitmask bit 7).
79    NodeJsAbsent,
80    /// `Navigator.prototype.webdriver` should look like a native accessor descriptor.
81    WebDriverDescriptorShape,
82    /// `navigator.userAgentData` should exist and expose coherent client hints.
83    UserAgentDataPresent,
84    /// `navigator.connection` should expose plausible network information.
85    ConnectionPresent,
86    /// Hidden font-probe elements should yield non-zero layout measurements.
87    HiddenFontProbeRect,
88    /// `screen` metrics and `devicePixelRatio` should be plausible and coherent.
89    ScreenMetricsCoherent,
90    /// The Web Audio surface should exist and expose a non-zero sample rate.
91    AudioContextPresent,
92}
93
94/// Stable identifier for a browser surface we do not yet spoof or validate fully.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum LimitationId {
98    /// WebGPU / `navigator.gpu` is exposed but not yet spoofed or validated.
99    WebGpuSurface,
100    /// `performance.memory` is exposed but not yet spoofed or validated.
101    PerformanceMemorySurface,
102    /// `navigator.storage` may be unavailable on opaque origins (e.g. `about:blank`).
103    OpaqueOriginStorage,
104}
105
106// ── CheckResult ───────────────────────────────────────────────────────────────
107
108/// The outcome of running a single detection check in the browser.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CheckResult {
111    /// Which check produced this result.
112    pub id: CheckId,
113    /// Human-readable description of what was tested.
114    pub description: String,
115    /// `true` if the browser appears legitimate for this check.
116    pub passed: bool,
117    /// Diagnostic detail returned by the JavaScript evaluation.
118    pub details: String,
119}
120
121/// A known browser surface that is visible but not yet covered by stealth diagnostics.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct KnownLimitation {
124    /// Which limitation was observed.
125    pub id: LimitationId,
126    /// Human-readable description of the uncovered surface.
127    pub description: String,
128    /// Runtime detail from the page context.
129    pub details: String,
130}
131
132/// Optional observed transport fingerprints to compare against expected values.
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct TransportObservations {
135    /// Observed JA3 hash (lower/upper hex accepted).
136    pub ja3_hash: Option<String>,
137    /// Observed JA4 fingerprint string.
138    pub ja4: Option<String>,
139    /// Observed HTTP/3 perk text (`SETTINGS|PSEUDO_HEADERS`).
140    pub http3_perk_text: Option<String>,
141    /// Observed HTTP/3 perk hash.
142    pub http3_perk_hash: Option<String>,
143}
144
145/// Transport-level diagnostics emitted alongside JavaScript stealth checks.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct TransportDiagnostic {
148    /// User-Agent sampled from the live page.
149    pub user_agent: String,
150    /// Built-in profile name inferred from User-Agent, if any.
151    pub expected_profile: Option<String>,
152    /// Expected JA3 raw string from the inferred profile.
153    pub expected_ja3_raw: Option<String>,
154    /// Expected JA3 hash from the inferred profile.
155    pub expected_ja3_hash: Option<String>,
156    /// Expected JA4 fingerprint from the inferred profile.
157    pub expected_ja4: Option<String>,
158    /// Expected HTTP/3 perk text derived from User-Agent.
159    pub expected_http3_perk_text: Option<String>,
160    /// Expected HTTP/3 perk hash derived from User-Agent.
161    pub expected_http3_perk_hash: Option<String>,
162    /// Caller-supplied observed transport values.
163    pub observed: TransportObservations,
164    /// `true` when all supplied observations match expected fingerprints.
165    /// `None` when no observations were supplied.
166    pub transport_match: Option<bool>,
167    /// Human-readable mismatch reasons.
168    pub mismatches: Vec<String>,
169}
170
171impl TransportDiagnostic {
172    /// Build transport diagnostics from `user_agent` and optional observations.
173    #[must_use]
174    pub fn from_user_agent_and_observations(
175        user_agent: &str,
176        observed: Option<&TransportObservations>,
177    ) -> Self {
178        let observed = observed.cloned().unwrap_or_default();
179
180        // Resolve profile once; derive all fingerprints from it to avoid repeated UA parsing.
181        let expected_profile = crate::tls::expected_tls_profile_from_user_agent(user_agent);
182        let expected_ja3 = expected_profile.map(crate::tls::TlsProfile::ja3);
183        let expected_ja4 = expected_profile.map(crate::tls::TlsProfile::ja4);
184        let expected_http3 = expected_profile.and_then(crate::tls::TlsProfile::http3_perk);
185
186        let mut mismatches = Vec::new();
187
188        if let (Some(expected), Some(observed_hash)) = (
189            expected_ja3.as_ref().map(|j| j.hash.as_str()),
190            observed.ja3_hash.as_deref(),
191        ) && !observed_hash.eq_ignore_ascii_case(expected)
192        {
193            mismatches.push(format!(
194                "ja3_hash mismatch: expected '{expected}', observed '{observed_hash}'"
195            ));
196        }
197
198        if let (Some(expected), Some(observed_ja4)) = (
199            expected_ja4.as_ref().map(|j| j.fingerprint.as_str()),
200            observed.ja4.as_deref(),
201        ) && observed_ja4 != expected
202        {
203            mismatches.push(format!(
204                "ja4 mismatch: expected '{expected}', observed '{observed_ja4}'"
205            ));
206        }
207
208        if let Some(expected) = expected_http3.as_ref() {
209            let cmp = expected.compare(
210                observed.http3_perk_text.as_deref(),
211                observed.http3_perk_hash.as_deref(),
212            );
213            mismatches.extend(cmp.mismatches);
214        }
215
216        // If callers supplied observed transport fields that cannot be compared
217        // due to missing expectations, surface that explicitly instead of
218        // reporting a false positive match.
219        if observed.ja3_hash.is_some() && expected_ja3.is_none() {
220            mismatches.push(
221                "ja3_hash was provided but no expected JA3 could be derived from user-agent"
222                    .to_string(),
223            );
224        }
225        if observed.ja4.is_some() && expected_ja4.is_none() {
226            mismatches.push(
227                "ja4 was provided but no expected JA4 could be derived from user-agent".to_string(),
228            );
229        }
230        if (observed.http3_perk_text.is_some() || observed.http3_perk_hash.is_some())
231            && expected_http3.is_none()
232        {
233            mismatches.push(
234                "http3 perk observation was provided but no expected HTTP/3 fingerprint could be derived from user-agent"
235                    .to_string(),
236            );
237        }
238
239        let has_observed = observed.ja3_hash.is_some()
240            || observed.ja4.is_some()
241            || observed.http3_perk_text.is_some()
242            || observed.http3_perk_hash.is_some();
243
244        Self {
245            user_agent: user_agent.to_string(),
246            expected_profile: expected_profile.map(|p| p.name.clone()),
247            expected_ja3_raw: expected_ja3.as_ref().map(|j| j.raw.clone()),
248            expected_ja3_hash: expected_ja3.as_ref().map(|j| j.hash.clone()),
249            expected_ja4: expected_ja4.as_ref().map(|j| j.fingerprint.clone()),
250            expected_http3_perk_text: expected_http3
251                .as_ref()
252                .map(crate::tls::Http3Perk::perk_text),
253            expected_http3_perk_hash: expected_http3
254                .as_ref()
255                .map(crate::tls::Http3Perk::perk_hash),
256            observed,
257            transport_match: has_observed.then_some(mismatches.is_empty()),
258            mismatches,
259        }
260    }
261}
262
263// ── DiagnosticReport ──────────────────────────────────────────────────────────
264
265/// Aggregate result from running all detection checks.
266///
267/// # Example
268///
269/// ```
270/// use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
271///
272/// let results = all_checks()
273///     .iter()
274///     .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
275///     .collect::<Vec<_>>();
276/// let report = DiagnosticReport::new(results);
277/// assert!(report.is_clean());
278/// assert!((report.coverage_pct() - 100.0).abs() < 0.001);
279/// ```
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct DiagnosticReport {
282    /// Individual check results in catalogue order.
283    pub checks: Vec<CheckResult>,
284    /// Number of checks where `passed == true`.
285    pub passed_count: usize,
286    /// Number of checks where `passed == false`.
287    pub failed_count: usize,
288    /// Browser surfaces observed at runtime that are not yet covered fully.
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub known_limitations: Vec<KnownLimitation>,
291    /// Optional transport-layer diagnostics (JA3/JA4/HTTP3 perk).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub transport: Option<TransportDiagnostic>,
294}
295
296impl DiagnosticReport {
297    /// Build a report from an ordered list of check results.
298    pub fn new(checks: Vec<CheckResult>) -> Self {
299        let passed_count = checks.iter().filter(|r| r.passed).count();
300        let failed_count = checks.len() - passed_count;
301        Self {
302            checks,
303            passed_count,
304            failed_count,
305            known_limitations: Vec::new(),
306            transport: None,
307        }
308    }
309
310    /// Attach known browser-surface limitations to this report.
311    #[must_use]
312    pub fn with_known_limitations(mut self, known_limitations: Vec<KnownLimitation>) -> Self {
313        self.known_limitations = known_limitations;
314        self
315    }
316
317    /// Attach transport diagnostics to this report.
318    #[must_use]
319    pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
320        self.transport = Some(transport);
321        self
322    }
323
324    /// Returns `true` when every check passed.
325    #[must_use]
326    pub const fn is_clean(&self) -> bool {
327        self.failed_count == 0
328    }
329
330    /// Percentage of checks that passed (0.0–100.0).
331    #[allow(clippy::cast_precision_loss)]
332    pub fn coverage_pct(&self) -> f64 {
333        if self.checks.is_empty() {
334            return 0.0;
335        }
336        self.passed_count as f64 / self.checks.len() as f64 * 100.0
337    }
338
339    /// Iterate over all checks that returned `passed: false`.
340    pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
341        self.checks.iter().filter(|r| !r.passed)
342    }
343}
344
345// ── DetectionCheck ────────────────────────────────────────────────────────────
346
347/// A single stealth detection check: identifier, description, and JavaScript
348/// to evaluate via CDP `Runtime.evaluate`.
349pub struct DetectionCheck {
350    /// Stable identifier.
351    pub id: CheckId,
352    /// Human-readable description of what this check tests.
353    pub description: &'static str,
354    /// Self-contained JavaScript expression that **must** evaluate to a JSON
355    /// string with shape `'{"passed":true|false,"details":"..."}'`.
356    ///
357    /// The expression is sent verbatim to CDP `Runtime.evaluate`.  Use IIFEs
358    /// (`(function(){ ... })()`) for multi-statement scripts.
359    pub script: &'static str,
360}
361
362/// A runtime probe for a visible browser surface we do not yet fully cover.
363pub struct LimitationProbe {
364    /// Stable identifier.
365    pub id: LimitationId,
366    /// Human-readable description.
367    pub description: &'static str,
368    /// JavaScript expression returning a JSON string with shape
369    /// `'{"limited":true|false,"details":"..."}'`.
370    pub script: &'static str,
371}
372
373impl DetectionCheck {
374    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
375    ///
376    /// If the JSON is invalid (e.g. the script threw an exception), returns a
377    /// failing [`CheckResult`] with the raw output in `details` (conservative
378    /// fallback — avoids silently hiding problems).
379    pub fn parse_output(&self, json: &str) -> CheckResult {
380        #[derive(Deserialize)]
381        struct Output {
382            passed: bool,
383            #[serde(default)]
384            details: String,
385        }
386
387        match serde_json::from_str::<Output>(json) {
388            Ok(o) => CheckResult {
389                id: self.id,
390                description: self.description.to_string(),
391                passed: o.passed,
392                details: o.details,
393            },
394            Err(e) => CheckResult {
395                id: self.id,
396                description: self.description.to_string(),
397                passed: false,
398                details: format!("parse error: {e} | raw: {json}"),
399            },
400        }
401    }
402}
403
404impl LimitationProbe {
405    fn limitation(&self, details: String) -> KnownLimitation {
406        KnownLimitation {
407            id: self.id,
408            description: self.description.to_string(),
409            details,
410        }
411    }
412
413    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
414    pub fn parse_output(&self, json: &str) -> Option<KnownLimitation> {
415        #[derive(Deserialize)]
416        struct Output {
417            limited: bool,
418            #[serde(default)]
419            details: String,
420        }
421
422        match serde_json::from_str::<Output>(json) {
423            Ok(output) => output.limited.then(|| self.limitation(output.details)),
424            Err(error) => Some(self.limitation(format!("parse error: {error} | raw: {json}"))),
425        }
426    }
427}
428
429// ── Built-in JavaScript scripts ───────────────────────────────────────────────
430
431const SCRIPT_WEBDRIVER: &str = concat!(
432    "JSON.stringify({",
433    "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
434    "details:String(navigator.webdriver)",
435    "})"
436);
437
438const SCRIPT_CHROME_OBJECT: &str = concat!(
439    "JSON.stringify({",
440    "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
441    "&&typeof window.chrome.runtime!=='undefined',",
442    "details:typeof window.chrome",
443    "})"
444);
445
446const SCRIPT_PLUGIN_COUNT: &str = concat!(
447    "JSON.stringify({",
448    "passed:navigator.plugins.length>0,",
449    "details:navigator.plugins.length+' plugins'",
450    "})"
451);
452
453const SCRIPT_LANGUAGES: &str = concat!(
454    "JSON.stringify({",
455    "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
456    "details:JSON.stringify(navigator.languages)",
457    "})"
458);
459
460const SCRIPT_CANVAS: &str = concat!(
461    "(function(){",
462    "var c=document.createElement('canvas');",
463    "c.width=200;c.height=50;",
464    "var ctx=c.getContext('2d');",
465    "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
466    "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
467    "ctx.fillText('stygian-diag',10,30);",
468    "var d=c.toDataURL();",
469    "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
470    "})()"
471);
472
473const SCRIPT_WEBGL_VENDOR: &str = concat!(
474    "(function(){",
475    "var gl=document.createElement('canvas').getContext('webgl');",
476    "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
477    "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
478    "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
479    "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
480    "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
481    "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
482    "return JSON.stringify({passed:!sw,details:v+'/'+r});",
483    "})()"
484);
485
486const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
487    "JSON.stringify({",
488    "passed:typeof window.__puppeteer__==='undefined'",
489    "&&typeof window.__playwright==='undefined'",
490    "&&typeof window.__webdriverFunc==='undefined'",
491    "&&typeof window._phantom==='undefined',",
492    "details:'automation globals checked'",
493    "})"
494);
495
496const SCRIPT_OUTER_WINDOW: &str = concat!(
497    "JSON.stringify({",
498    "passed:window.outerWidth>0&&window.outerHeight>0,",
499    "details:window.outerWidth+'x'+window.outerHeight",
500    "})"
501);
502
503const SCRIPT_HEADLESS_UA: &str = concat!(
504    "JSON.stringify({",
505    "passed:!navigator.userAgent.includes('HeadlessChrome'),",
506    "details:navigator.userAgent.substring(0,100)",
507    "})"
508);
509
510const SCRIPT_NOTIFICATION: &str = concat!(
511    "JSON.stringify({",
512    "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
513    "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
514    "})"
515);
516
517const SCRIPT_MATCH_MEDIA: &str = concat!(
518    "JSON.stringify({",
519    "passed:typeof window.matchMedia==='function',",
520    "details:typeof window.matchMedia",
521    "})"
522);
523
524const SCRIPT_ELEMENT_FROM_POINT: &str = concat!(
525    "JSON.stringify({",
526    "passed:typeof document.elementFromPoint==='function',",
527    "details:typeof document.elementFromPoint",
528    "})"
529);
530
531const SCRIPT_RAF: &str = concat!(
532    "JSON.stringify({",
533    "passed:typeof window.requestAnimationFrame==='function',",
534    "details:typeof window.requestAnimationFrame",
535    "})"
536);
537
538const SCRIPT_GET_COMPUTED_STYLE: &str = concat!(
539    "JSON.stringify({",
540    "passed:typeof window.getComputedStyle==='function',",
541    "details:typeof window.getComputedStyle",
542    "})"
543);
544
545const SCRIPT_CSS_SUPPORTS: &str = concat!(
546    "JSON.stringify({",
547    "passed:typeof CSS!=='undefined'&&typeof CSS.supports==='function',",
548    "details:typeof CSS!=='undefined'?typeof CSS.supports:'undefined'",
549    "})"
550);
551
552const SCRIPT_SEND_BEACON: &str = concat!(
553    "JSON.stringify({",
554    "passed:typeof navigator.sendBeacon==='function',",
555    "details:typeof navigator.sendBeacon",
556    "})"
557);
558
559const SCRIPT_EXEC_COMMAND: &str = concat!(
560    "JSON.stringify({",
561    "passed:typeof document.execCommand==='function',",
562    "details:typeof document.execCommand",
563    "})"
564);
565
566const SCRIPT_NODEJS_ABSENT: &str = concat!(
567    "JSON.stringify({",
568    "passed:typeof process==='undefined'",
569    "||process.versions==null",
570    "||typeof process.versions.node==='undefined',",
571    "details:typeof process",
572    "})"
573);
574
575const SCRIPT_WEBDRIVER_DESCRIPTOR: &str = concat!(
576    "(function(){",
577    "var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'webdriver');",
578    "var ok=typeof d==='undefined'||(typeof d.get==='function'&&d.set===undefined&&d.configurable===true);",
579    "var detail=d?('getter='+typeof d.get+',set='+typeof d.set+',configurable='+String(d.configurable)+',enumerable='+String(d.enumerable)):'missing';",
580    "return JSON.stringify({passed:ok,details:detail});",
581    "})()"
582);
583
584const SCRIPT_USER_AGENT_DATA: &str = concat!(
585    "(function(){",
586    "var d=navigator.userAgentData;",
587    "var ok=typeof d==='undefined'||(Array.isArray(d.brands)&&d.brands.length>0&&typeof d.mobile==='boolean'&&typeof d.getHighEntropyValues==='function');",
588    "var detail=typeof d==='undefined'?'undefined':('brands='+(Array.isArray(d.brands)?d.brands.length:0)+',mobile='+String(d.mobile)+',platform='+(d.platform||''));",
589    "return JSON.stringify({passed:ok,details:detail});",
590    "})()"
591);
592
593const SCRIPT_CONNECTION: &str = concat!(
594    "(function(){",
595    "var c=navigator.connection;",
596    "var ok=typeof c!=='undefined'&&typeof c.rtt==='number'&&c.rtt>=0&&typeof c.downlink==='number'&&c.downlink>=0&&typeof c.effectiveType==='string'&&c.effectiveType.length>0;",
597    "var detail=typeof c==='undefined'?'undefined':('rtt='+String(c.rtt)+',downlink='+String(c.downlink)+',effectiveType='+(c.effectiveType||''));",
598    "return JSON.stringify({passed:ok,details:detail});",
599    "})()"
600);
601
602const SCRIPT_STORAGE_ESTIMATE: &str = concat!(
603    "(function(){",
604    "var s=navigator.storage;",
605    "var limited=!s||typeof s.estimate!=='function';",
606    "var detail=!s?'storage unavailable':typeof s.estimate;",
607    "return JSON.stringify({limited:limited,details:detail});",
608    "})()"
609);
610
611const SCRIPT_HIDDEN_FONT_PROBE: &str = concat!(
612    "(function(){",
613    "var root=document.body||document.documentElement;",
614    "if(!root){return JSON.stringify({passed:false,details:'no root element available'});}",
615    "var probe=document.createElement('div');",
616    "probe.textContent='mmmmmmmmmlli';",
617    "probe.setAttribute('aria-hidden','true');",
618    "probe.style.position='absolute';",
619    "probe.style.visibility='hidden';",
620    "probe.style.font='16px Arial';",
621    "root.appendChild(probe);",
622    "var rect=probe.getBoundingClientRect();",
623    "probe.remove();",
624    "var ok=rect.width>0&&rect.height>0;",
625    "return JSON.stringify({passed:ok,details:'width='+rect.width+',height='+rect.height});",
626    "})()"
627);
628
629const SCRIPT_SCREEN_METRICS: &str = concat!(
630    "JSON.stringify({",
631    "passed:screen.width>0&&screen.height>0&&screen.availWidth>0&&screen.availHeight>0&&screen.availWidth<=screen.width&&screen.availHeight<=screen.height&&window.devicePixelRatio>0,",
632    "details:'screen='+screen.width+'x'+screen.height+',avail='+screen.availWidth+'x'+screen.availHeight+',dpr='+window.devicePixelRatio",
633    "})"
634);
635
636const SCRIPT_AUDIO_CONTEXT: &str = concat!(
637    "(function(){",
638    "var C=window.AudioContext||window.webkitAudioContext;",
639    "if(!C)return JSON.stringify({passed:false,details:'AudioContext unavailable'});",
640    "var ctx=new C();",
641    "var sampleRate=ctx.sampleRate||0;",
642    "var baseLatency=typeof ctx.baseLatency==='number'?ctx.baseLatency:-1;",
643    "if(typeof ctx.close==='function'){ctx.close();}",
644    "return JSON.stringify({passed:sampleRate>0,details:'sampleRate='+sampleRate+',baseLatency='+baseLatency});",
645    "})()"
646);
647
648const SCRIPT_WEBGPU_LIMITATION: &str = concat!(
649    "JSON.stringify({",
650    "limited:'gpu' in navigator,",
651    "details:typeof navigator.gpu",
652    "})"
653);
654
655const SCRIPT_PERFORMANCE_MEMORY_LIMITATION: &str = concat!(
656    "JSON.stringify({",
657    "limited:typeof performance.memory!=='undefined',",
658    "details:typeof performance.memory",
659    "})"
660);
661
662// ── Static check catalogue ────────────────────────────────────────────────────
663
664/// Return all built-in stealth detection checks.
665///
666/// Iterate the slice, send each `check.script` to the browser via CDP, then
667/// call [`DetectionCheck::parse_output`] with the returned JSON string.
668pub fn all_checks() -> &'static [DetectionCheck] {
669    CHECKS
670}
671
672/// Return all known browser-surface limitation probes.
673pub fn all_limitation_probes() -> &'static [LimitationProbe] {
674    LIMITATION_PROBES
675}
676
677static CHECKS: &[DetectionCheck] = &[
678    DetectionCheck {
679        id: CheckId::WebDriverFlag,
680        description: "navigator.webdriver must be false/undefined",
681        script: SCRIPT_WEBDRIVER,
682    },
683    DetectionCheck {
684        id: CheckId::ChromeObject,
685        description: "window.chrome.runtime must exist",
686        script: SCRIPT_CHROME_OBJECT,
687    },
688    DetectionCheck {
689        id: CheckId::PluginCount,
690        description: "navigator.plugins must be non-empty",
691        script: SCRIPT_PLUGIN_COUNT,
692    },
693    DetectionCheck {
694        id: CheckId::LanguagesPresent,
695        description: "navigator.languages must be non-empty",
696        script: SCRIPT_LANGUAGES,
697    },
698    DetectionCheck {
699        id: CheckId::CanvasConsistency,
700        description: "canvas toDataURL must return non-trivial image data",
701        script: SCRIPT_CANVAS,
702    },
703    DetectionCheck {
704        id: CheckId::WebGlVendor,
705        description: "WebGL vendor must not be SwiftShader (software renderer)",
706        script: SCRIPT_WEBGL_VENDOR,
707    },
708    DetectionCheck {
709        id: CheckId::AutomationGlobals,
710        description: "automation globals (Puppeteer/Playwright) must be absent",
711        script: SCRIPT_AUTOMATION_GLOBALS,
712    },
713    DetectionCheck {
714        id: CheckId::OuterWindowSize,
715        description: "window.outerWidth/outerHeight must be non-zero",
716        script: SCRIPT_OUTER_WINDOW,
717    },
718    DetectionCheck {
719        id: CheckId::HeadlessUserAgent,
720        description: "User-Agent must not contain 'HeadlessChrome'",
721        script: SCRIPT_HEADLESS_UA,
722    },
723    DetectionCheck {
724        id: CheckId::NotificationPermission,
725        description: "Notification.permission must not be pre-granted",
726        script: SCRIPT_NOTIFICATION,
727    },
728    DetectionCheck {
729        id: CheckId::MatchMediaPresent,
730        description: "window.matchMedia must be a function (PX env-bitmask bit 0)",
731        script: SCRIPT_MATCH_MEDIA,
732    },
733    DetectionCheck {
734        id: CheckId::ElementFromPointPresent,
735        description: "document.elementFromPoint must be a function (PX env-bitmask bit 1)",
736        script: SCRIPT_ELEMENT_FROM_POINT,
737    },
738    DetectionCheck {
739        id: CheckId::RequestAnimationFramePresent,
740        description: "window.requestAnimationFrame must be a function (PX env-bitmask bit 2)",
741        script: SCRIPT_RAF,
742    },
743    DetectionCheck {
744        id: CheckId::GetComputedStylePresent,
745        description: "window.getComputedStyle must be a function (PX env-bitmask bit 3)",
746        script: SCRIPT_GET_COMPUTED_STYLE,
747    },
748    DetectionCheck {
749        id: CheckId::CssSupportsPresent,
750        description: "CSS.supports must exist and be callable (PX env-bitmask bit 4)",
751        script: SCRIPT_CSS_SUPPORTS,
752    },
753    DetectionCheck {
754        id: CheckId::SendBeaconPresent,
755        description: "navigator.sendBeacon must be a function (PX env-bitmask bit 5)",
756        script: SCRIPT_SEND_BEACON,
757    },
758    DetectionCheck {
759        id: CheckId::ExecCommandPresent,
760        description: "document.execCommand must be a function (PX env-bitmask bit 6)",
761        script: SCRIPT_EXEC_COMMAND,
762    },
763    DetectionCheck {
764        id: CheckId::NodeJsAbsent,
765        description: "process.versions.node must be absent — not a Node.js environment (PX env-bitmask bit 7)",
766        script: SCRIPT_NODEJS_ABSENT,
767    },
768    DetectionCheck {
769        id: CheckId::WebDriverDescriptorShape,
770        description: "Navigator.prototype.webdriver must look like an accessor descriptor",
771        script: SCRIPT_WEBDRIVER_DESCRIPTOR,
772    },
773    DetectionCheck {
774        id: CheckId::UserAgentDataPresent,
775        description: "navigator.userAgentData must expose coherent client hints",
776        script: SCRIPT_USER_AGENT_DATA,
777    },
778    DetectionCheck {
779        id: CheckId::ConnectionPresent,
780        description: "navigator.connection must expose plausible network information",
781        script: SCRIPT_CONNECTION,
782    },
783    DetectionCheck {
784        id: CheckId::HiddenFontProbeRect,
785        description: "hidden font probes must yield non-zero layout measurements",
786        script: SCRIPT_HIDDEN_FONT_PROBE,
787    },
788    DetectionCheck {
789        id: CheckId::ScreenMetricsCoherent,
790        description: "screen metrics and devicePixelRatio must be coherent",
791        script: SCRIPT_SCREEN_METRICS,
792    },
793    DetectionCheck {
794        id: CheckId::AudioContextPresent,
795        description: "AudioContext must expose a non-zero sample rate",
796        script: SCRIPT_AUDIO_CONTEXT,
797    },
798];
799
800static LIMITATION_PROBES: &[LimitationProbe] = &[
801    LimitationProbe {
802        id: LimitationId::WebGpuSurface,
803        description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated",
804        script: SCRIPT_WEBGPU_LIMITATION,
805    },
806    LimitationProbe {
807        id: LimitationId::PerformanceMemorySurface,
808        description: "performance.memory is exposed but not yet spoofed or validated",
809        script: SCRIPT_PERFORMANCE_MEMORY_LIMITATION,
810    },
811    LimitationProbe {
812        id: LimitationId::OpaqueOriginStorage,
813        description: "navigator.storage is unavailable or incomplete on this origin",
814        script: SCRIPT_STORAGE_ESTIMATE,
815    },
816];
817
818// ── tests ─────────────────────────────────────────────────────────────────────
819
820#[cfg(test)]
821#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
822mod tests {
823    use super::*;
824    use std::collections::HashSet;
825
826    #[test]
827    fn all_checks_returns_eighteen_entries() {
828        assert_eq!(all_checks().len(), 24);
829    }
830
831    #[test]
832    fn all_limitation_probes_returns_two_entries() {
833        assert_eq!(all_limitation_probes().len(), 3);
834    }
835
836    #[test]
837    fn all_checks_have_unique_ids() {
838        let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
839        assert_eq!(
840            ids.len(),
841            all_checks().len(),
842            "duplicate check ids detected"
843        );
844    }
845
846    #[test]
847    fn all_checks_have_non_empty_scripts_with_json_stringify() {
848        for check in all_checks() {
849            assert!(
850                !check.script.is_empty(),
851                "check {:?} has empty script",
852                check.id
853            );
854            assert!(
855                check.script.contains("JSON.stringify"),
856                "check {:?} script must produce a JSON string",
857                check.id
858            );
859        }
860    }
861
862    #[test]
863    fn parse_output_valid_passing_json() {
864        let check = &all_checks()[0]; // WebDriverFlag
865        let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
866        assert!(result.passed);
867        assert_eq!(result.id, CheckId::WebDriverFlag);
868        assert_eq!(result.details, "undefined");
869    }
870
871    #[test]
872    fn parse_output_valid_failing_json() {
873        let check = &all_checks()[0];
874        let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
875        assert!(!result.passed);
876    }
877
878    #[test]
879    fn parse_output_invalid_json_returns_fail_with_details() {
880        let check = &all_checks()[0];
881        let result = check.parse_output("not json at all");
882        assert!(!result.passed);
883        assert!(result.details.contains("parse error"));
884    }
885
886    #[test]
887    fn parse_output_preserves_check_id() {
888        let check = all_checks()
889            .iter()
890            .find(|c| c.id == CheckId::ChromeObject)
891            .unwrap();
892        let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
893        assert_eq!(result.id, CheckId::ChromeObject);
894        assert_eq!(result.description, check.description);
895    }
896
897    #[test]
898    fn parse_output_missing_details_defaults_to_empty() {
899        let check = &all_checks()[0];
900        let result = check.parse_output(r#"{"passed":true}"#);
901        assert!(result.passed);
902        assert!(result.details.is_empty());
903    }
904
905    #[test]
906    fn diagnostic_report_all_passing() {
907        let results: Vec<CheckResult> = all_checks()
908            .iter()
909            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
910            .collect();
911        let report = DiagnosticReport::new(results);
912        assert!(report.is_clean());
913        assert_eq!(report.passed_count, 24);
914        assert!(report.known_limitations.is_empty());
915        assert_eq!(report.failed_count, 0);
916        assert!((report.coverage_pct() - 100.0).abs() < 0.001);
917        assert_eq!(report.failures().count(), 0);
918    }
919
920    #[test]
921    fn diagnostic_report_some_failing() {
922        let mut results: Vec<CheckResult> = all_checks()
923            .iter()
924            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
925            .collect();
926        results[0].passed = false;
927        results[2].passed = false;
928        let report = DiagnosticReport::new(results);
929        assert!(!report.is_clean());
930        assert_eq!(report.failed_count, 2);
931        assert_eq!(report.passed_count, 22);
932        assert_eq!(report.failures().count(), 2);
933    }
934
935    #[test]
936    fn diagnostic_report_empty_checks() {
937        let report = DiagnosticReport::new(Vec::new());
938        assert!(report.is_clean()); // vacuously true
939        assert!((report.coverage_pct()).abs() < 0.001);
940    }
941
942    #[test]
943    fn check_result_serializes_with_snake_case_id() {
944        let result = CheckResult {
945            id: CheckId::WebDriverFlag,
946            description: "test".to_string(),
947            passed: true,
948            details: "ok".to_string(),
949        };
950        let json = serde_json::to_string(&result).unwrap();
951        assert!(json.contains("\"web_driver_flag\""), "got: {json}");
952        assert!(json.contains("\"passed\":true"));
953    }
954
955    #[test]
956    fn diagnostic_report_serializes_and_deserializes() {
957        let results: Vec<CheckResult> = all_checks()
958            .iter()
959            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
960            .collect();
961        let report = DiagnosticReport::new(results).with_known_limitations(vec![KnownLimitation {
962            id: LimitationId::WebGpuSurface,
963            description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated"
964                .to_string(),
965            details: "object".to_string(),
966        }]);
967        let json = serde_json::to_string(&report).unwrap();
968        let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
969        assert_eq!(restored.passed_count, report.passed_count);
970        assert_eq!(restored.known_limitations.len(), 1);
971        assert!(restored.is_clean());
972    }
973
974    #[test]
975    fn limitation_probe_reports_surface_when_limited() {
976        let probe = &all_limitation_probes()[0];
977        let limitation = probe
978            .parse_output(r#"{"limited":true,"details":"object"}"#)
979            .unwrap();
980        assert_eq!(limitation.id, LimitationId::WebGpuSurface);
981        assert_eq!(limitation.details, "object");
982    }
983
984    #[test]
985    fn limitation_probe_returns_none_when_surface_not_limited() {
986        let probe = &all_limitation_probes()[0];
987        assert!(
988            probe
989                .parse_output(r#"{"limited":false,"details":"undefined"}"#)
990                .is_none()
991        );
992    }
993
994    #[test]
995    fn transport_diagnostic_reports_match_for_matching_observations() {
996        let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
997        let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
998
999        // Ensure test UA resolves at least one expected fingerprint.
1000        assert!(
1001            expected.expected_profile.is_some()
1002                || expected.expected_ja3_hash.is_some()
1003                || expected.expected_ja4.is_some()
1004                || expected.expected_http3_perk_text.is_some()
1005        );
1006
1007        let observed = TransportObservations {
1008            ja3_hash: expected.expected_ja3_hash.clone(),
1009            ja4: expected.expected_ja4.clone(),
1010            http3_perk_text: expected.expected_http3_perk_text.clone(),
1011            http3_perk_hash: expected.expected_http3_perk_hash,
1012        };
1013        let diagnostic =
1014            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1015
1016        assert_eq!(diagnostic.transport_match, Some(true));
1017        assert!(diagnostic.mismatches.is_empty());
1018    }
1019
1020    #[test]
1021    fn transport_diagnostic_reports_mismatch_for_mismatching_observations() {
1022        let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1023        let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1024
1025        assert!(expected.expected_ja3_hash.is_some());
1026
1027        let observed = TransportObservations {
1028            ja3_hash: Some("definitely-not-the-expected-ja3".to_string()),
1029            ja4: expected.expected_ja4.clone(),
1030            http3_perk_text: expected.expected_http3_perk_text.clone(),
1031            http3_perk_hash: expected.expected_http3_perk_hash,
1032        };
1033        let diagnostic =
1034            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1035
1036        assert_eq!(diagnostic.transport_match, Some(false));
1037        assert!(!diagnostic.mismatches.is_empty());
1038        assert!(
1039            diagnostic
1040                .mismatches
1041                .iter()
1042                .any(|m| m.contains("ja3_hash mismatch"))
1043        );
1044    }
1045
1046    #[test]
1047    fn transport_diagnostic_flags_observations_when_no_expectations_derivable() {
1048        let user_agent = "UnknownBrowser/0.0";
1049        let diagnostic_without_observed =
1050            TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1051
1052        // Unknown UA should not resolve any expectations.
1053        assert_eq!(diagnostic_without_observed.expected_profile, None);
1054        assert_eq!(diagnostic_without_observed.expected_ja3_hash, None);
1055        assert_eq!(diagnostic_without_observed.expected_ja4, None);
1056        assert_eq!(diagnostic_without_observed.expected_http3_perk_text, None);
1057
1058        let observed = TransportObservations {
1059            ja3_hash: Some("some-observed-ja3".to_string()),
1060            ja4: Some("some-observed-ja4".to_string()),
1061            http3_perk_text: Some("some-observed-http3-perk-text".to_string()),
1062            http3_perk_hash: Some("some-observed-http3-perk-hash".to_string()),
1063        };
1064
1065        let diagnostic =
1066            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1067
1068        // With observations but no expectations, mismatches should be flagged.
1069        assert_eq!(diagnostic.transport_match, Some(false));
1070        assert!(!diagnostic.mismatches.is_empty());
1071        assert!(
1072            diagnostic
1073                .mismatches
1074                .iter()
1075                .any(|m| m.contains("no expected JA3 could be derived"))
1076        );
1077    }
1078}