Skip to main content

stygian_browser/integrity_canary/
probes.rs

1//! Integrity probe catalogue.
2//!
3//! Defines the [`IntegrityProbe`] record, the [`IntegrityProbeId`]
4//! taxonomy, the [`IntegrityProbeOutcome`] severity, and the
5//! per-probe mitigation hint text.
6//!
7//! Probes are pure data (no I/O). Their JavaScript `script` fields
8//! are designed to be sent verbatim to CDP `Runtime.evaluate` by
9//! the consumer (browser automation), and the resulting JSON
10//! output is decoded by [`IntegrityProbe::parse_output`] into a
11//! [`ProbeFinding`].
12//!
13//! All scripts use the broadest-compatibility form (old-style
14//! `var` / `function()` / `Array.prototype.slice.call`, no arrow
15//! functions or template literals) so they run on the same browser
16//! engines the existing stealth injection targets.
17
18use serde::{Deserialize, Serialize};
19
20/// Stable identifier for a built-in integrity probe.
21///
22/// New variants are additive — existing variants keep their
23/// discriminant and `serde` label across releases so consumers can
24/// safely branch on the wire format.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum IntegrityProbeId {
28    /// `Object.getOwnPropertyDescriptor(Navigator.prototype, "webdriver")`
29    /// must be an accessor (getter), not a data property. Patched
30    /// stealth implementations frequently redefine `webdriver` as a
31    /// data property.
32    WebDriverDescriptorNative,
33    /// `Function.prototype.toString` applied to a known native
34    /// method must report `[native code]`. Patched natives that
35    /// override `toString` without preserving the native code
36    /// marker leak patch source.
37    FunctionToStringNative,
38    /// `(function(){}).toString()` must report `function anonymous() {
39    /// [native code] }` — a clean function literal must look native.
40    /// Patched frames occasionally return the wrapped function body
41    /// instead of `[native code]`.
42    ErrorToStringNative,
43    /// `Intl.DateTimeFormat.prototype.format.toString()` must
44    /// contain `[native code]`.
45    IntlDateTimeFormatNative,
46    /// `RegExp.prototype.test.toString()` must contain `[native code]`.
47    RegExpTestNative,
48    /// `CanvasRenderingContext2D.prototype.getImageData.toString()`
49    /// must contain `[native code]`.
50    CanvasGetImageDataNative,
51    /// `performance.now()` resolution should be plausibly
52    /// microsecond-scale and not quantized (e.g. values forced to
53    /// 0.1 ms ticks).
54    PerformanceNowResolution,
55    /// `new Proxy({}, { ownKeys: () => [] })` must report no own
56    /// keys — patched natives that trap `[[OwnPropertyKeys]]`
57    /// through a `Proxy` leak the trap to detection scripts.
58    ProxyTrapObservable,
59}
60
61impl IntegrityProbeId {
62    /// Stable `snake_case` label used in telemetry.
63    #[must_use]
64    pub const fn label(self) -> &'static str {
65        match self {
66            Self::WebDriverDescriptorNative => "webdriver_descriptor_native",
67            Self::FunctionToStringNative => "function_to_string_native",
68            Self::ErrorToStringNative => "error_to_string_native",
69            Self::IntlDateTimeFormatNative => "intl_date_time_format_native",
70            Self::RegExpTestNative => "regexp_test_native",
71            Self::CanvasGetImageDataNative => "canvas_get_image_data_native",
72            Self::PerformanceNowResolution => "performance_now_resolution",
73            Self::ProxyTrapObservable => "proxy_trap_observable",
74        }
75    }
76}
77
78impl std::fmt::Display for IntegrityProbeId {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(self.label())
81    }
82}
83
84/// Severity outcome of an integrity probe.
85///
86/// `Skipped` is **not** an error — it marks a probe that could not
87/// run (the relevant API was not exposed on the page, e.g. an
88/// opaque-origin iframe). Skipped probes contribute **zero** risk to
89/// the aggregate score and are excluded from the denominator (so
90/// partial probe coverage does not pull the score toward zero).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum IntegrityProbeOutcome {
94    /// Probe ran and the surface looks native / unpatched.
95    Clean,
96    /// Probe ran and the surface shows an ambiguous anomaly that
97    /// is consistent with either a stealth patch or a browser
98    /// polyfill.
99    TrapSuspected,
100    /// Probe ran and the surface shows a deterministic patch
101    /// artefact (e.g. `webdriver` is a data property on
102    /// `Navigator.prototype`).
103    TrapConfirmed,
104    /// Probe could not run (API not exposed, exception thrown,
105    /// etc.). No panic, no risk contribution.
106    Skipped,
107}
108
109impl IntegrityProbeOutcome {
110    /// Stable `snake_case` label.
111    #[must_use]
112    pub const fn label(self) -> &'static str {
113        match self {
114            Self::Clean => "clean",
115            Self::TrapSuspected => "trap_suspected",
116            Self::TrapConfirmed => "trap_confirmed",
117            Self::Skipped => "skipped",
118        }
119    }
120
121    /// Severity multiplier used by the score formula:
122    ///
123    /// - `Clean`     → `0.0` (no risk contribution)
124    /// - `TrapSuspected` → `0.5` (ambiguous signal)
125    /// - `TrapConfirmed` → `1.0` (deterministic regression)
126    /// - `Skipped`   → `0.0` AND excluded from the denominator
127    ///
128    /// Exposing this as a constant helper keeps the scoring formula
129    /// in one place so future risk weighting stays consistent.
130    #[must_use]
131    pub const fn severity(self) -> f64 {
132        match self {
133            Self::Clean | Self::Skipped => 0.0,
134            Self::TrapSuspected => 0.5,
135            Self::TrapConfirmed => 1.0,
136        }
137    }
138
139    /// `true` when the outcome counts toward the aggregate score
140    /// (i.e. the probe actually ran and produced a verdict).
141    #[must_use]
142    pub const fn contributes(self) -> bool {
143        !matches!(self, Self::Skipped)
144    }
145}
146
147impl std::fmt::Display for IntegrityProbeOutcome {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(self.label())
150    }
151}
152
153/// Single integrity probe definition: stable id, weight, JS
154/// evaluation script, description, and mitigation hint.
155///
156/// The catalogue lives in [`all_probes`]. The struct is `Clone` so
157/// callers can build modified copies for testing.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct IntegrityProbe {
160    /// Stable probe identifier.
161    pub id: IntegrityProbeId,
162    /// Weight in `[0.0, 1.0]` used by the aggregate score.
163    pub weight: f64,
164    /// Human-readable description (one short paragraph).
165    pub description: &'static str,
166    /// JavaScript evaluation script (verbatim). Returns a JSON
167    /// string with shape
168    /// `'{"outcome":"clean|trap_suspected|trap_confirmed|skipped","evidence":"..."}'`.
169    pub script: &'static str,
170    /// Actionable mitigation hint emitted in the diagnostic
171    /// payload when the probe fires.
172    pub mitigation_hint: &'static str,
173}
174
175impl IntegrityProbe {
176    /// Build a finding representing a confirmed trap (test helper).
177    #[must_use]
178    pub fn confirmed_finding(
179        id: impl Into<String>,
180        weight: f64,
181        evidence: impl Into<String>,
182    ) -> ProbeFinding {
183        ProbeFinding {
184            id: id.into(),
185            outcome: IntegrityProbeOutcome::TrapConfirmed,
186            weight,
187            evidence: evidence.into(),
188            mitigation_hint: String::new(),
189        }
190    }
191
192    /// Parse the JSON string returned by the probe's `script`.
193    ///
194    /// Returns a [`ProbeFinding`] with the recorded outcome and
195    /// evidence. When JSON parsing fails, the result is mapped to a
196    /// [`IntegrityProbeOutcome::Skipped`] finding carrying the raw
197    /// output as evidence — this is the conservative fallback that
198    /// keeps the report deterministic under parse failures.
199    #[must_use]
200    pub fn parse_output(&self, json: &str) -> ProbeFinding {
201        #[derive(Deserialize)]
202        struct Output {
203            outcome: String,
204            #[serde(default)]
205            evidence: String,
206        }
207        match serde_json::from_str::<Output>(json) {
208            Ok(o) => {
209                let outcome = match o.outcome.as_str() {
210                    "clean" => IntegrityProbeOutcome::Clean,
211                    "trap_suspected" => IntegrityProbeOutcome::TrapSuspected,
212                    "trap_confirmed" => IntegrityProbeOutcome::TrapConfirmed,
213                    _ => IntegrityProbeOutcome::Skipped,
214                };
215                ProbeFinding {
216                    id: self.id.label().to_string(),
217                    outcome,
218                    weight: self.weight,
219                    evidence: o.evidence,
220                    mitigation_hint: self.mitigation_hint.to_string(),
221                }
222            }
223            Err(err) => ProbeFinding {
224                id: self.id.label().to_string(),
225                outcome: IntegrityProbeOutcome::Skipped,
226                weight: self.weight,
227                evidence: format!("parse error: {err} | raw: {json}"),
228                mitigation_hint: self.mitigation_hint.to_string(),
229            },
230        }
231    }
232}
233
234/// Captured result of a single integrity probe evaluation.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct ProbeFinding {
237    /// Probe identifier (`snake_case` label).
238    pub id: String,
239    /// Resolved outcome.
240    pub outcome: IntegrityProbeOutcome,
241    /// Recorded weight at evaluation time.
242    pub weight: f64,
243    /// Free-form evidence returned by the JavaScript evaluation
244    /// (e.g. `"function webdriver() {}"` for a patched `webdriver`
245    /// accessor).
246    pub evidence: String,
247    /// Per-probe mitigation hint copied from the catalogue. Empty
248    /// when the catalogue entry has no hint.
249    pub mitigation_hint: String,
250}
251
252impl ProbeFinding {
253    /// `true` when the probe fired with a [`IntegrityProbeOutcome::TrapSuspected`]
254    /// or [`IntegrityProbeOutcome::TrapConfirmed`] outcome.
255    #[must_use]
256    pub const fn is_trap(&self) -> bool {
257        matches!(
258            self.outcome,
259            IntegrityProbeOutcome::TrapSuspected | IntegrityProbeOutcome::TrapConfirmed
260        )
261    }
262
263    /// `true` when the probe reported a confirmed trap.
264    #[must_use]
265    pub const fn is_confirmed(&self) -> bool {
266        matches!(self.outcome, IntegrityProbeOutcome::TrapConfirmed)
267    }
268
269    /// Numeric contribution of this finding to the aggregate risk
270    /// score (weight × severity). Returns `0.0` for skipped findings.
271    #[must_use]
272    pub fn contribution(&self) -> f64 {
273        if !self.outcome.contributes() {
274            return 0.0;
275        }
276        self.weight * self.outcome.severity()
277    }
278}
279
280// ─── Built-in probe scripts ──────────────────────────────────────────────────
281
282const SCRIPT_WEBDRIVER_DESCRIPTOR: &str = concat!(
283    "(function(){",
284    "var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'webdriver');",
285    "if(typeof d==='undefined'){return JSON.stringify({outcome:'clean',evidence:'no descriptor'});}",
286    "if(typeof d.get!=='function'||typeof d.set!=='undefined'){",
287    "return JSON.stringify({outcome:'trap_confirmed',",
288    "evidence:'descriptor is data property (get='+typeof d.get+', set='+typeof d.set+')'});",
289    "}",
290    "if(d.configurable===false&&d.enumerable===false){",
291    "return JSON.stringify({outcome:'trap_suspected',",
292    "evidence:'accessor present but non-configurable (configurable='+String(d.configurable)+')'});",
293    "}",
294    "return JSON.stringify({outcome:'clean',evidence:'accessor present and configurable'});",
295    "})()"
296);
297
298const SCRIPT_FUNCTION_TO_STRING: &str = concat!(
299    "(function(){",
300    "var native='function () { [native code] }';",
301    "var keys=['getTime','now','random','test','format'];",
302    "var host=typeof Intl!=='undefined'?Intl.DateTimeFormat.prototype:null;",
303    "if(!host||typeof host.format!=='function'){",
304    "return JSON.stringify({outcome:'skipped',evidence:'Intl.DateTimeFormat.format unavailable'});",
305    "}",
306    "var s=Function.prototype.toString.call(host.format);",
307    "if(s.indexOf('[native code]')===-1){",
308    "return JSON.stringify({outcome:'trap_confirmed',evidence:s.substring(0,80)});",
309    "}",
310    "return JSON.stringify({outcome:'clean',evidence:'[native code] marker present'});",
311    "})()"
312);
313
314const SCRIPT_ERROR_TO_STRING: &str = concat!(
315    "(function(){",
316    "var s=Function.prototype.toString.call(function(){});",
317    "if(s.indexOf('[native code]')!==-1){",
318    "return JSON.stringify({outcome:'clean',evidence:'[native code] marker present'});",
319    "}",
320    "if(s.substring(0,8)==='function'){",
321    "return JSON.stringify({outcome:'trap_suspected',evidence:s.substring(0,80)});",
322    "}",
323    "return JSON.stringify({outcome:'trap_confirmed',evidence:s.substring(0,80)});",
324    "})()"
325);
326
327const SCRIPT_INTL_DATE_TIME_FORMAT: &str = concat!(
328    "(function(){",
329    "if(typeof Intl==='undefined'||typeof Intl.DateTimeFormat==='undefined'){",
330    "return JSON.stringify({outcome:'skipped',evidence:'Intl.DateTimeFormat unavailable'});",
331    "}",
332    "var s=Function.prototype.toString.call(Intl.DateTimeFormat.prototype.format);",
333    "if(s.indexOf('[native code]')===-1){",
334    "return JSON.stringify({outcome:'trap_confirmed',evidence:s.substring(0,80)});",
335    "}",
336    "return JSON.stringify({outcome:'clean',evidence:'[native code] marker present'});",
337    "})()"
338);
339
340const SCRIPT_REGEXP_TEST: &str = concat!(
341    "(function(){",
342    "var s=Function.prototype.toString.call(RegExp.prototype.test);",
343    "if(s.indexOf('[native code]')===-1){",
344    "return JSON.stringify({outcome:'trap_confirmed',evidence:s.substring(0,80)});",
345    "}",
346    "return JSON.stringify({outcome:'clean',evidence:'[native code] marker present'});",
347    "})()"
348);
349
350const SCRIPT_CANVAS_GET_IMAGE_DATA: &str = concat!(
351    "(function(){",
352    "var s=Function.prototype.toString.call(CanvasRenderingContext2D.prototype.getImageData);",
353    "if(s.indexOf('[native code]')===-1){",
354    "return JSON.stringify({outcome:'trap_confirmed',evidence:s.substring(0,80)});",
355    "}",
356    "return JSON.stringify({outcome:'clean',evidence:'[native code] marker present'});",
357    "})()"
358);
359
360const SCRIPT_PERFORMANCE_NOW_RESOLUTION: &str = concat!(
361    "(function(){",
362    "if(typeof performance==='undefined'||typeof performance.now!=='function'){",
363    "return JSON.stringify({outcome:'skipped',evidence:'performance.now unavailable'});",
364    "}",
365    "var samples=[];",
366    "for(var i=0;i<20;i++){samples.push(performance.now());}",
367    "var deltas=[];",
368    "for(var j=1;j<samples.length;j++){deltas.push(samples[j]-samples[j-1]);}",
369    "deltas.sort(function(a,b){return a-b;});",
370    "var median=deltas[Math.floor(deltas.length/2)];",
371    "if(median<=0){return JSON.stringify({outcome:'skipped',evidence:'zero deltas'});}",
372    "var ratio=median/Math.round(median);",
373    "if(Math.abs(ratio-1)>0.05&&median>0.05){",
374    "return JSON.stringify({outcome:'trap_suspected',",
375    "evidence:'median delta='+median.toFixed(4)+'ms deviates from a clean tick'});",
376    "}",
377    "if(median>=5){",
378    "return JSON.stringify({outcome:'trap_suspected',",
379    "evidence:'median delta='+median.toFixed(4)+'ms looks quantized'});",
380    "}",
381    "return JSON.stringify({outcome:'clean',evidence:'median delta='+median.toFixed(4)+'ms'});",
382    "})()"
383);
384
385const SCRIPT_PROXY_TRAP_OBSERVABLE: &str = concat!(
386    "(function(){",
387    "var keys;",
388    "try{",
389    "keys=Object.keys(new Proxy({},{ownKeys:function(){return ['__patched__'];}}));",
390    "}catch(e){",
391    "return JSON.stringify({outcome:'skipped',evidence:'Proxy unavailable: '+String(e)});",
392    "}",
393    "if(keys.length===0){",
394    "return JSON.stringify({outcome:'clean',evidence:'proxy ownKeys trap observable (expected)'});",
395    "}",
396    "if(keys.indexOf('__patched__')!==-1){",
397    "return JSON.stringify({outcome:'trap_suspected',",
398    "evidence:'proxy ownKeys trap returns custom keys: '+JSON.stringify(keys)});",
399    "}",
400    "return JSON.stringify({outcome:'clean',evidence:'proxy keys='+JSON.stringify(keys)});",
401    "})()"
402);
403
404// ─── Probe catalogue ─────────────────────────────────────────────────────────
405
406/// Built-in integrity probe catalogue.
407///
408/// Each probe has a stable id, a weight in `[0.0, 1.0]`, a
409/// human-readable description, a self-contained JavaScript
410/// evaluation script, and a per-probe mitigation hint. The total
411/// weight of the catalogue is `1.0` so the aggregate score is a
412/// weighted average in `[0.0, 1.0]`.
413pub static PROBES: &[IntegrityProbe] = &[
414    IntegrityProbe {
415        id: IntegrityProbeId::WebDriverDescriptorNative,
416        weight: 0.20,
417        description: "Navigator.prototype.webdriver must be an accessor descriptor (getter + no setter)",
418        script: SCRIPT_WEBDRIVER_DESCRIPTOR,
419        mitigation_hint: "Re-define navigator.webdriver via Object.defineProperty with a native-shaped accessor (configurable: true, enumerable: false, getter returns false). Avoid data-property overrides that leak the patch artefact.",
420    },
421    IntegrityProbe {
422        id: IntegrityProbeId::FunctionToStringNative,
423        weight: 0.18,
424        description: "Function.prototype.toString must report [native code] for native methods",
425        script: SCRIPT_FUNCTION_TO_STRING,
426        mitigation_hint: "Preserve the [native code] marker on patched prototype methods (use Object.defineProperty with the native function as the value, not a wrapper). If a polyfill is unavoidable, override toString to return the canonical 'function name() { [native code] }' shape.",
427    },
428    IntegrityProbe {
429        id: IntegrityProbeId::ErrorToStringNative,
430        weight: 0.08,
431        description: "(function(){}).toString() must look native",
432        script: SCRIPT_ERROR_TO_STRING,
433        mitigation_hint: "Avoid wrapping function literals in proxy / decorator chains that intercept toString. Browser vendors intentionally return 'function () { [native code] }' for empty literals.",
434    },
435    IntegrityProbe {
436        id: IntegrityProbeId::IntlDateTimeFormatNative,
437        weight: 0.10,
438        description: "Intl.DateTimeFormat.prototype.format must be a native function",
439        script: SCRIPT_INTL_DATE_TIME_FORMAT,
440        mitigation_hint: "Do not monkey-patch Intl.DateTimeFormat.prototype.format — anti-bot scripts probe this surface explicitly. Override at the call site instead (use a wrapper around Date.prototype.toLocaleString).",
441    },
442    IntegrityProbe {
443        id: IntegrityProbeId::RegExpTestNative,
444        weight: 0.08,
445        description: "RegExp.prototype.test must be a native function",
446        script: SCRIPT_REGEXP_TEST,
447        mitigation_hint: "Avoid replacing RegExp.prototype.test with a wrapper. If pattern instrumentation is required, do it via a custom regex helper function rather than prototype mutation.",
448    },
449    IntegrityProbe {
450        id: IntegrityProbeId::CanvasGetImageDataNative,
451        weight: 0.10,
452        description: "CanvasRenderingContext2D.prototype.getImageData must be a native function",
453        script: SCRIPT_CANVAS_GET_IMAGE_DATA,
454        mitigation_hint: "Apply canvas fingerprint noise at the pixel-data layer (post getImageData) rather than by overriding getImageData itself. Vendors fingerprint the descriptor shape first.",
455    },
456    IntegrityProbe {
457        id: IntegrityProbeId::PerformanceNowResolution,
458        weight: 0.14,
459        description: "performance.now() resolution must look plausible (microsecond-scale, not quantized)",
460        script: SCRIPT_PERFORMANCE_NOW_RESOLUTION,
461        mitigation_hint: "Replace timing-noise quantization with a continuous distribution (Gaussian jitter, stddev ~5-25 µs) or apply noise at the consumer layer (performance.now callers) rather than patching performance.now itself.",
462    },
463    IntegrityProbe {
464        id: IntegrityProbeId::ProxyTrapObservable,
465        weight: 0.12,
466        description: "Proxy ownKeys trap must not leak surface state on patched natives",
467        script: SCRIPT_PROXY_TRAP_OBSERVABLE,
468        mitigation_hint: "When wrapping native objects, return the canonical ownKeys list ([] for empty wrappers) and never expose a 'patched' sentinel key through the trap. Detection scripts diff the trap output against the real underlying object.",
469    },
470];
471
472/// Return the full built-in integrity probe catalogue.
473#[must_use]
474pub fn all_probes() -> &'static [IntegrityProbe] {
475    PROBES
476}
477
478/// Look up a probe by its stable identifier.
479///
480/// Returns `None` when the id is unknown — this lets callers branch
481/// safely on `serde_json::from_str` results without panicking.
482#[must_use]
483pub fn probe_by_id(id: IntegrityProbeId) -> Option<&'static IntegrityProbe> {
484    PROBES.iter().find(|p| p.id == id)
485}
486
487// ─── Tests ────────────────────────────────────────────────────────────────────
488
489#[cfg(test)]
490#[allow(
491    clippy::unwrap_used,
492    clippy::expect_used,
493    clippy::panic,
494    clippy::indexing_slicing
495)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn probe_ids_have_stable_labels() {
501        assert_eq!(
502            IntegrityProbeId::WebDriverDescriptorNative.label(),
503            "webdriver_descriptor_native"
504        );
505        assert_eq!(
506            IntegrityProbeId::PerformanceNowResolution.label(),
507            "performance_now_resolution"
508        );
509    }
510
511    #[test]
512    fn outcome_labels_are_stable() {
513        assert_eq!(IntegrityProbeOutcome::Clean.label(), "clean");
514        assert_eq!(
515            IntegrityProbeOutcome::TrapSuspected.label(),
516            "trap_suspected"
517        );
518        assert_eq!(
519            IntegrityProbeOutcome::TrapConfirmed.label(),
520            "trap_confirmed"
521        );
522        assert_eq!(IntegrityProbeOutcome::Skipped.label(), "skipped");
523    }
524
525    #[test]
526    fn outcome_severity_matches_documented_formula() {
527        assert!(approx_eq(IntegrityProbeOutcome::Clean.severity(), 0.0));
528        assert!(approx_eq(
529            IntegrityProbeOutcome::TrapSuspected.severity(),
530            0.5
531        ));
532        assert!(approx_eq(
533            IntegrityProbeOutcome::TrapConfirmed.severity(),
534            1.0
535        ));
536        assert!(approx_eq(IntegrityProbeOutcome::Skipped.severity(), 0.0));
537    }
538
539    #[test]
540    fn outcome_contributes_excludes_skipped() {
541        assert!(IntegrityProbeOutcome::Clean.contributes());
542        assert!(IntegrityProbeOutcome::TrapSuspected.contributes());
543        assert!(IntegrityProbeOutcome::TrapConfirmed.contributes());
544        assert!(!IntegrityProbeOutcome::Skipped.contributes());
545    }
546
547    #[test]
548    fn probe_catalogue_has_eight_entries() {
549        assert_eq!(all_probes().len(), 8);
550    }
551
552    #[test]
553    fn probe_catalogue_has_unique_ids() {
554        let mut seen = std::collections::HashSet::new();
555        for probe in all_probes() {
556            assert!(seen.insert(probe.id), "duplicate probe id: {:?}", probe.id);
557        }
558    }
559
560    #[test]
561    fn probe_weights_sum_to_one() {
562        let total: f64 = all_probes().iter().map(|p| p.weight).sum();
563        assert!(
564            (total - 1.0).abs() < 1e-9,
565            "total probe weight must be 1.0, got: {total}"
566        );
567    }
568
569    #[test]
570    fn probe_weights_are_in_unit_interval() {
571        for probe in all_probes() {
572            assert!(
573                (0.0..=1.0).contains(&probe.weight),
574                "probe {:?} weight {} outside [0.0, 1.0]",
575                probe.id,
576                probe.weight
577            );
578        }
579    }
580
581    #[test]
582    fn probe_scripts_are_non_empty_and_emit_json() {
583        for probe in all_probes() {
584            assert!(!probe.script.is_empty());
585            assert!(probe.script.contains("JSON.stringify"));
586        }
587    }
588
589    #[test]
590    fn probe_mitigation_hints_are_non_empty() {
591        for probe in all_probes() {
592            assert!(
593                !probe.mitigation_hint.is_empty(),
594                "probe {:?} has empty mitigation_hint",
595                probe.id
596            );
597            assert!(
598                probe.mitigation_hint.len() >= 40,
599                "probe {:?} mitigation_hint is suspiciously short",
600                probe.id
601            );
602        }
603    }
604
605    #[test]
606    fn parse_output_clean_passing_json() {
607        let probe = &all_probes()[0]; // WebDriverDescriptorNative
608        let finding = probe
609            .parse_output(r#"{"outcome":"clean","evidence":"accessor present and configurable"}"#);
610        assert_eq!(finding.id, "webdriver_descriptor_native");
611        assert_eq!(finding.outcome, IntegrityProbeOutcome::Clean);
612        assert_eq!(finding.evidence, "accessor present and configurable");
613        assert!(!finding.is_trap());
614        assert!(!finding.is_confirmed());
615    }
616
617    #[test]
618    fn parse_output_confirmed_trap_json() {
619        let probe = &all_probes()[0];
620        let finding = probe.parse_output(
621            r#"{"outcome":"trap_confirmed","evidence":"descriptor is data property"}"#,
622        );
623        assert_eq!(finding.outcome, IntegrityProbeOutcome::TrapConfirmed);
624        assert!(finding.is_trap());
625        assert!(finding.is_confirmed());
626        assert!(approx_eq(finding.contribution(), probe.weight));
627    }
628
629    #[test]
630    fn parse_output_suspected_trap_json() {
631        let probe = &all_probes()[0];
632        let finding = probe.parse_output(r#"{"outcome":"trap_suspected","evidence":"unclear"}"#);
633        assert_eq!(finding.outcome, IntegrityProbeOutcome::TrapSuspected);
634        assert!(finding.is_trap());
635        assert!(!finding.is_confirmed());
636        assert!((probe.weight.mul_add(-0.5, finding.contribution())).abs() < 1e-9);
637    }
638
639    #[test]
640    fn parse_output_skipped_json() {
641        let probe = &all_probes()[0];
642        let finding = probe.parse_output(r#"{"outcome":"skipped","evidence":"unavailable"}"#);
643        assert_eq!(finding.outcome, IntegrityProbeOutcome::Skipped);
644        assert!(!finding.is_trap());
645        assert!(approx_eq(finding.contribution(), 0.0));
646    }
647
648    #[test]
649    fn parse_output_invalid_json_returns_skipped_with_raw() {
650        let probe = &all_probes()[0];
651        let finding = probe.parse_output("not json at all");
652        assert_eq!(finding.outcome, IntegrityProbeOutcome::Skipped);
653        assert!(finding.evidence.contains("parse error"));
654        assert!(finding.evidence.contains("not json at all"));
655    }
656
657    #[test]
658    fn parse_output_unknown_outcome_label_returns_skipped() {
659        let probe = &all_probes()[0];
660        let finding = probe.parse_output(r#"{"outcome":"mystery","evidence":"?"}"#);
661        assert_eq!(finding.outcome, IntegrityProbeOutcome::Skipped);
662    }
663
664    #[test]
665    fn parse_output_copies_mitigation_hint_from_catalogue() {
666        let probe = &all_probes()[0];
667        let finding = probe.parse_output(r#"{"outcome":"trap_confirmed","evidence":"x"}"#);
668        assert_eq!(finding.mitigation_hint, probe.mitigation_hint);
669    }
670
671    #[test]
672    fn probe_by_id_resolves_known_ids() {
673        for probe in all_probes() {
674            assert_eq!(probe_by_id(probe.id).map(|p| p.id), Some(probe.id));
675        }
676    }
677
678    #[test]
679    fn confirmed_finding_helper_uses_provided_weight() {
680        let f = IntegrityProbe::confirmed_finding("test_probe", 0.25, "evidence");
681        assert_eq!(f.id, "test_probe");
682        assert!(approx_eq(f.weight, 0.25));
683        assert_eq!(f.outcome, IntegrityProbeOutcome::TrapConfirmed);
684        assert!(f.is_confirmed());
685    }
686
687    fn approx_eq(a: f64, b: f64) -> bool {
688        (a - b).abs() < 1e-9
689    }
690}