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}
65
66// ── CheckResult ───────────────────────────────────────────────────────────────
67
68/// The outcome of running a single detection check in the browser.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CheckResult {
71    /// Which check produced this result.
72    pub id: CheckId,
73    /// Human-readable description of what was tested.
74    pub description: String,
75    /// `true` if the browser appears legitimate for this check.
76    pub passed: bool,
77    /// Diagnostic detail returned by the JavaScript evaluation.
78    pub details: String,
79}
80
81// ── DiagnosticReport ──────────────────────────────────────────────────────────
82
83/// Aggregate result from running all detection checks.
84///
85/// # Example
86///
87/// ```
88/// use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
89///
90/// let results = all_checks()
91///     .iter()
92///     .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
93///     .collect::<Vec<_>>();
94/// let report = DiagnosticReport::new(results);
95/// assert!(report.is_clean());
96/// assert!((report.coverage_pct() - 100.0).abs() < 0.001);
97/// ```
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DiagnosticReport {
100    /// Individual check results in catalogue order.
101    pub checks: Vec<CheckResult>,
102    /// Number of checks where `passed == true`.
103    pub passed_count: usize,
104    /// Number of checks where `passed == false`.
105    pub failed_count: usize,
106}
107
108impl DiagnosticReport {
109    /// Build a report from an ordered list of check results.
110    pub fn new(checks: Vec<CheckResult>) -> Self {
111        let passed_count = checks.iter().filter(|r| r.passed).count();
112        let failed_count = checks.len() - passed_count;
113        Self {
114            checks,
115            passed_count,
116            failed_count,
117        }
118    }
119
120    /// Returns `true` when every check passed.
121    #[must_use]
122    pub const fn is_clean(&self) -> bool {
123        self.failed_count == 0
124    }
125
126    /// Percentage of checks that passed (0.0–100.0).
127    #[allow(clippy::cast_precision_loss)]
128    pub fn coverage_pct(&self) -> f64 {
129        if self.checks.is_empty() {
130            return 0.0;
131        }
132        self.passed_count as f64 / self.checks.len() as f64 * 100.0
133    }
134
135    /// Iterate over all checks that returned `passed: false`.
136    pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
137        self.checks.iter().filter(|r| !r.passed)
138    }
139}
140
141// ── DetectionCheck ────────────────────────────────────────────────────────────
142
143/// A single stealth detection check: identifier, description, and JavaScript
144/// to evaluate via CDP `Runtime.evaluate`.
145pub struct DetectionCheck {
146    /// Stable identifier.
147    pub id: CheckId,
148    /// Human-readable description of what this check tests.
149    pub description: &'static str,
150    /// Self-contained JavaScript expression that **must** evaluate to a JSON
151    /// string with shape `'{"passed":true|false,"details":"..."}'`.
152    ///
153    /// The expression is sent verbatim to CDP `Runtime.evaluate`.  Use IIFEs
154    /// (`(function(){ ... })()`) for multi-statement scripts.
155    pub script: &'static str,
156}
157
158impl DetectionCheck {
159    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
160    ///
161    /// If the JSON is invalid (e.g. the script threw an exception), returns a
162    /// failing [`CheckResult`] with the raw output in `details` (conservative
163    /// fallback — avoids silently hiding problems).
164    pub fn parse_output(&self, json: &str) -> CheckResult {
165        #[derive(Deserialize)]
166        struct Output {
167            passed: bool,
168            #[serde(default)]
169            details: String,
170        }
171
172        match serde_json::from_str::<Output>(json) {
173            Ok(o) => CheckResult {
174                id: self.id,
175                description: self.description.to_string(),
176                passed: o.passed,
177                details: o.details,
178            },
179            Err(e) => CheckResult {
180                id: self.id,
181                description: self.description.to_string(),
182                passed: false,
183                details: format!("parse error: {e} | raw: {json}"),
184            },
185        }
186    }
187}
188
189// ── Built-in JavaScript scripts ───────────────────────────────────────────────
190
191const SCRIPT_WEBDRIVER: &str = concat!(
192    "JSON.stringify({",
193    "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
194    "details:String(navigator.webdriver)",
195    "})"
196);
197
198const SCRIPT_CHROME_OBJECT: &str = concat!(
199    "JSON.stringify({",
200    "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
201    "&&typeof window.chrome.runtime!=='undefined',",
202    "details:typeof window.chrome",
203    "})"
204);
205
206const SCRIPT_PLUGIN_COUNT: &str = concat!(
207    "JSON.stringify({",
208    "passed:navigator.plugins.length>0,",
209    "details:navigator.plugins.length+' plugins'",
210    "})"
211);
212
213const SCRIPT_LANGUAGES: &str = concat!(
214    "JSON.stringify({",
215    "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
216    "details:JSON.stringify(navigator.languages)",
217    "})"
218);
219
220const SCRIPT_CANVAS: &str = concat!(
221    "(function(){",
222    "var c=document.createElement('canvas');",
223    "c.width=200;c.height=50;",
224    "var ctx=c.getContext('2d');",
225    "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
226    "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
227    "ctx.fillText('stygian-diag',10,30);",
228    "var d=c.toDataURL();",
229    "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
230    "})()"
231);
232
233const SCRIPT_WEBGL_VENDOR: &str = concat!(
234    "(function(){",
235    "var gl=document.createElement('canvas').getContext('webgl');",
236    "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
237    "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
238    "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
239    "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
240    "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
241    "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
242    "return JSON.stringify({passed:!sw,details:v+'/'+r});",
243    "})()"
244);
245
246const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
247    "JSON.stringify({",
248    "passed:typeof window.__puppeteer__==='undefined'",
249    "&&typeof window.__playwright==='undefined'",
250    "&&typeof window.__webdriverFunc==='undefined'",
251    "&&typeof window._phantom==='undefined',",
252    "details:'automation globals checked'",
253    "})"
254);
255
256const SCRIPT_OUTER_WINDOW: &str = concat!(
257    "JSON.stringify({",
258    "passed:window.outerWidth>0&&window.outerHeight>0,",
259    "details:window.outerWidth+'x'+window.outerHeight",
260    "})"
261);
262
263const SCRIPT_HEADLESS_UA: &str = concat!(
264    "JSON.stringify({",
265    "passed:!navigator.userAgent.includes('HeadlessChrome'),",
266    "details:navigator.userAgent.substring(0,100)",
267    "})"
268);
269
270const SCRIPT_NOTIFICATION: &str = concat!(
271    "JSON.stringify({",
272    "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
273    "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
274    "})"
275);
276
277// ── Static check catalogue ────────────────────────────────────────────────────
278
279/// Return all built-in stealth detection checks.
280///
281/// Iterate the slice, send each `check.script` to the browser via CDP, then
282/// call [`DetectionCheck::parse_output`] with the returned JSON string.
283pub fn all_checks() -> &'static [DetectionCheck] {
284    CHECKS
285}
286
287static CHECKS: &[DetectionCheck] = &[
288    DetectionCheck {
289        id: CheckId::WebDriverFlag,
290        description: "navigator.webdriver must be false/undefined",
291        script: SCRIPT_WEBDRIVER,
292    },
293    DetectionCheck {
294        id: CheckId::ChromeObject,
295        description: "window.chrome.runtime must exist",
296        script: SCRIPT_CHROME_OBJECT,
297    },
298    DetectionCheck {
299        id: CheckId::PluginCount,
300        description: "navigator.plugins must be non-empty",
301        script: SCRIPT_PLUGIN_COUNT,
302    },
303    DetectionCheck {
304        id: CheckId::LanguagesPresent,
305        description: "navigator.languages must be non-empty",
306        script: SCRIPT_LANGUAGES,
307    },
308    DetectionCheck {
309        id: CheckId::CanvasConsistency,
310        description: "canvas toDataURL must return non-trivial image data",
311        script: SCRIPT_CANVAS,
312    },
313    DetectionCheck {
314        id: CheckId::WebGlVendor,
315        description: "WebGL vendor must not be SwiftShader (software renderer)",
316        script: SCRIPT_WEBGL_VENDOR,
317    },
318    DetectionCheck {
319        id: CheckId::AutomationGlobals,
320        description: "automation globals (Puppeteer/Playwright) must be absent",
321        script: SCRIPT_AUTOMATION_GLOBALS,
322    },
323    DetectionCheck {
324        id: CheckId::OuterWindowSize,
325        description: "window.outerWidth/outerHeight must be non-zero",
326        script: SCRIPT_OUTER_WINDOW,
327    },
328    DetectionCheck {
329        id: CheckId::HeadlessUserAgent,
330        description: "User-Agent must not contain 'HeadlessChrome'",
331        script: SCRIPT_HEADLESS_UA,
332    },
333    DetectionCheck {
334        id: CheckId::NotificationPermission,
335        description: "Notification.permission must not be pre-granted",
336        script: SCRIPT_NOTIFICATION,
337    },
338];
339
340// ── tests ─────────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
344mod tests {
345    use super::*;
346    use std::collections::HashSet;
347
348    #[test]
349    fn all_checks_returns_ten_entries() {
350        assert_eq!(all_checks().len(), 10);
351    }
352
353    #[test]
354    fn all_checks_have_unique_ids() {
355        let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
356        assert_eq!(
357            ids.len(),
358            all_checks().len(),
359            "duplicate check ids detected"
360        );
361    }
362
363    #[test]
364    fn all_checks_have_non_empty_scripts_with_json_stringify() {
365        for check in all_checks() {
366            assert!(
367                !check.script.is_empty(),
368                "check {:?} has empty script",
369                check.id
370            );
371            assert!(
372                check.script.contains("JSON.stringify"),
373                "check {:?} script must produce a JSON string",
374                check.id
375            );
376        }
377    }
378
379    #[test]
380    fn parse_output_valid_passing_json() {
381        let check = &all_checks()[0]; // WebDriverFlag
382        let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
383        assert!(result.passed);
384        assert_eq!(result.id, CheckId::WebDriverFlag);
385        assert_eq!(result.details, "undefined");
386    }
387
388    #[test]
389    fn parse_output_valid_failing_json() {
390        let check = &all_checks()[0];
391        let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
392        assert!(!result.passed);
393    }
394
395    #[test]
396    fn parse_output_invalid_json_returns_fail_with_details() {
397        let check = &all_checks()[0];
398        let result = check.parse_output("not json at all");
399        assert!(!result.passed);
400        assert!(result.details.contains("parse error"));
401    }
402
403    #[test]
404    fn parse_output_preserves_check_id() {
405        let check = all_checks()
406            .iter()
407            .find(|c| c.id == CheckId::ChromeObject)
408            .unwrap();
409        let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
410        assert_eq!(result.id, CheckId::ChromeObject);
411        assert_eq!(result.description, check.description);
412    }
413
414    #[test]
415    fn parse_output_missing_details_defaults_to_empty() {
416        let check = &all_checks()[0];
417        let result = check.parse_output(r#"{"passed":true}"#);
418        assert!(result.passed);
419        assert!(result.details.is_empty());
420    }
421
422    #[test]
423    fn diagnostic_report_all_passing() {
424        let results: Vec<CheckResult> = all_checks()
425            .iter()
426            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
427            .collect();
428        let report = DiagnosticReport::new(results);
429        assert!(report.is_clean());
430        assert_eq!(report.passed_count, 10);
431        assert_eq!(report.failed_count, 0);
432        assert!((report.coverage_pct() - 100.0).abs() < 0.001);
433        assert_eq!(report.failures().count(), 0);
434    }
435
436    #[test]
437    fn diagnostic_report_some_failing() {
438        let mut results: Vec<CheckResult> = all_checks()
439            .iter()
440            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
441            .collect();
442        results[0].passed = false;
443        results[2].passed = false;
444        let report = DiagnosticReport::new(results);
445        assert!(!report.is_clean());
446        assert_eq!(report.failed_count, 2);
447        assert_eq!(report.passed_count, 8);
448        assert_eq!(report.failures().count(), 2);
449    }
450
451    #[test]
452    fn diagnostic_report_empty_checks() {
453        let report = DiagnosticReport::new(Vec::new());
454        assert!(report.is_clean()); // vacuously true
455        assert!((report.coverage_pct()).abs() < 0.001);
456    }
457
458    #[test]
459    fn check_result_serializes_with_snake_case_id() {
460        let result = CheckResult {
461            id: CheckId::WebDriverFlag,
462            description: "test".to_string(),
463            passed: true,
464            details: "ok".to_string(),
465        };
466        let json = serde_json::to_string(&result).unwrap();
467        assert!(json.contains("\"web_driver_flag\""), "got: {json}");
468        assert!(json.contains("\"passed\":true"));
469    }
470
471    #[test]
472    fn diagnostic_report_serializes_and_deserializes() {
473        let results: Vec<CheckResult> = all_checks()
474            .iter()
475            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
476            .collect();
477        let report = DiagnosticReport::new(results);
478        let json = serde_json::to_string(&report).unwrap();
479        let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
480        assert_eq!(restored.passed_count, report.passed_count);
481        assert!(restored.is_clean());
482    }
483}