1use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum IntegrityProbeId {
28 WebDriverDescriptorNative,
33 FunctionToStringNative,
38 ErrorToStringNative,
43 IntlDateTimeFormatNative,
46 RegExpTestNative,
48 CanvasGetImageDataNative,
51 PerformanceNowResolution,
55 ProxyTrapObservable,
59}
60
61impl IntegrityProbeId {
62 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum IntegrityProbeOutcome {
94 Clean,
96 TrapSuspected,
100 TrapConfirmed,
104 Skipped,
107}
108
109impl IntegrityProbeOutcome {
110 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct IntegrityProbe {
160 pub id: IntegrityProbeId,
162 pub weight: f64,
164 pub description: &'static str,
166 pub script: &'static str,
170 pub mitigation_hint: &'static str,
173}
174
175impl IntegrityProbe {
176 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct ProbeFinding {
237 pub id: String,
239 pub outcome: IntegrityProbeOutcome,
241 pub weight: f64,
243 pub evidence: String,
247 pub mitigation_hint: String,
250}
251
252impl ProbeFinding {
253 #[must_use]
256 pub const fn is_trap(&self) -> bool {
257 matches!(
258 self.outcome,
259 IntegrityProbeOutcome::TrapSuspected | IntegrityProbeOutcome::TrapConfirmed
260 )
261 }
262
263 #[must_use]
265 pub const fn is_confirmed(&self) -> bool {
266 matches!(self.outcome, IntegrityProbeOutcome::TrapConfirmed)
267 }
268
269 #[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
280const 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
404pub 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#[must_use]
474pub fn all_probes() -> &'static [IntegrityProbe] {
475 PROBES
476}
477
478#[must_use]
483pub fn probe_by_id(id: IntegrityProbeId) -> Option<&'static IntegrityProbe> {
484 PROBES.iter().find(|p| p.id == id)
485}
486
487#[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]; 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}