#![allow(dead_code)]
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone)]
pub struct VendorJsTarget {
pub name: String,
pub urls: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProbeSurface {
pub receiver: String,
pub property: String,
}
impl ProbeSurface {
pub fn new(receiver: impl Into<String>, property: impl Into<String>) -> Self {
Self {
receiver: receiver.into(),
property: property.into(),
}
}
}
impl fmt::Display for ProbeSurface {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.receiver, self.property)
}
}
#[derive(Debug, Clone, Default)]
pub struct ScrapeReport {
pub vendor: String,
pub urls_fetched: Vec<String>,
pub bytes_total: usize,
pub probes_total: usize,
pub probes_new: Vec<ProbeSurface>,
pub probes_removed: Vec<ProbeSurface>,
pub probes_stable: Vec<ProbeSurface>,
}
impl ScrapeReport {
pub fn render_summary(&self) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(512);
let _ = writeln!(out, "vendor: {}", self.vendor);
let _ = writeln!(out, "urls fetched: {}", self.urls_fetched.len());
let _ = writeln!(out, "bytes scanned: {}", self.bytes_total);
let _ = writeln!(out, "total probes: {}", self.probes_total);
let _ = writeln!(
out,
"new probes: {}, removed: {}, stable: {}",
self.probes_new.len(),
self.probes_removed.len(),
self.probes_stable.len()
);
if !self.probes_new.is_empty() {
out.push_str("\nNEW probes (need stealth coverage):\n");
for p in &self.probes_new {
let _ = writeln!(out, " + {p}");
}
}
if !self.probes_removed.is_empty() {
out.push_str("\nREMOVED probes (vendor stopped checking):\n");
for p in &self.probes_removed {
let _ = writeln!(out, " - {p}");
}
}
out
}
}
pub struct VendorJsScraper {
baselines: std::collections::HashMap<String, HashSet<ProbeSurface>>,
}
impl VendorJsScraper {
pub fn new() -> Self {
Self {
baselines: std::collections::HashMap::new(),
}
}
pub fn set_baseline(&mut self, vendor: impl Into<String>, probes: HashSet<ProbeSurface>) {
self.baselines.insert(vendor.into(), probes);
}
pub async fn scrape(
&mut self,
target: &VendorJsTarget,
client: &reqwest::Client,
) -> ScrapeReport {
let mut probes: HashSet<ProbeSurface> = HashSet::new();
let mut urls_fetched = Vec::new();
let mut bytes_total = 0usize;
for url in &target.urls {
match client.get(url).send().await {
Ok(resp) => match resp.text().await {
Ok(body) => {
bytes_total += body.len();
urls_fetched.push(url.clone());
for p in extract_probes(&body) {
probes.insert(p);
}
}
Err(e) => {
tracing::warn!(url, error = %e, "vendor_scraper: body read failed");
}
},
Err(e) => {
tracing::warn!(url, error = %e, "vendor_scraper: GET failed");
}
}
}
let baseline = self
.baselines
.get(&target.name)
.cloned()
.unwrap_or_default();
let mut probes_new: Vec<ProbeSurface> = probes.difference(&baseline).cloned().collect();
let mut probes_removed: Vec<ProbeSurface> = baseline.difference(&probes).cloned().collect();
let mut probes_stable: Vec<ProbeSurface> =
probes.intersection(&baseline).cloned().collect();
probes_new.sort();
probes_removed.sort();
probes_stable.sort();
self.baselines.insert(target.name.clone(), probes.clone());
ScrapeReport {
vendor: target.name.clone(),
urls_fetched,
bytes_total,
probes_total: probes.len(),
probes_new,
probes_removed,
probes_stable,
}
}
pub fn baseline(&self, vendor: &str) -> Option<&HashSet<ProbeSurface>> {
self.baselines.get(vendor)
}
}
impl Default for VendorJsScraper {
fn default() -> Self {
Self::new()
}
}
pub fn extract_probes(js: &str) -> Vec<ProbeSurface> {
let mut out: HashSet<ProbeSurface> = HashSet::new();
for receiver in FINGERPRINT_RECEIVERS {
let needle = format!("{receiver}.");
let bytes = js.as_bytes();
let mut cursor = 0usize;
while let Some(rel) = js[cursor..].find(&needle) {
let pos = cursor + rel;
let left_ok = pos == 0 || !is_ident_char(bytes[pos - 1] as char);
if !left_ok {
cursor = pos + 1;
continue;
}
let prop_start = pos + needle.len();
let mut prop_end = prop_start;
while prop_end < bytes.len() && is_ident_char(bytes[prop_end] as char) {
prop_end += 1;
}
if prop_end > prop_start {
let prop = &js[prop_start..prop_end];
if !is_reserved(prop) {
out.insert(ProbeSurface::new(*receiver, prop));
}
}
cursor = prop_end.max(pos + 1);
}
}
let mut v: Vec<ProbeSurface> = out.into_iter().collect();
v.sort();
v
}
const FINGERPRINT_RECEIVERS: &[&str] = &[
"navigator",
"window",
"document",
"screen",
"history",
"WebGLRenderingContext",
"WebGL2RenderingContext",
"CanvasRenderingContext2D",
"OffscreenCanvas",
"AudioContext",
"BaseAudioContext",
"RTCPeerConnection",
"MediaDevices",
"Battery",
"BatteryManager",
"Notification",
"PerformanceNavigation",
"Intl",
"Date",
"Math",
];
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '$'
}
fn is_reserved(word: &str) -> bool {
matches!(
word,
"then"
| "catch"
| "finally"
| "constructor"
| "prototype"
| "toString"
| "valueOf"
| "hasOwnProperty"
| "isPrototypeOf"
| "propertyIsEnumerable"
| "__proto__"
| "length"
)
}
pub fn bundled_targets() -> Vec<VendorJsTarget> {
vec![
VendorJsTarget {
name: "cloudflare-turnstile".into(),
urls: vec!["https://challenges.cloudflare.com/turnstile/v0/api.js".into()],
},
VendorJsTarget {
name: "hcaptcha".into(),
urls: vec!["https://hcaptcha.com/1/api.js".into()],
},
VendorJsTarget {
name: "recaptcha-v2".into(),
urls: vec!["https://www.google.com/recaptcha/api.js".into()],
},
VendorJsTarget {
name: "datadome".into(),
urls: vec!["https://js.datadome.co/tags.js".into()],
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_probes_finds_navigator_webdriver() {
let js = "if(navigator.webdriver){return false;}";
let probes = extract_probes(js);
assert!(probes.contains(&ProbeSurface::new("navigator", "webdriver")));
}
#[test]
fn extract_probes_finds_multi_namespace_probes() {
let js = r#"
const a = navigator.userAgent;
const b = window.chrome;
const c = document.referrer;
const d = screen.width;
const e = WebGLRenderingContext.UNMASKED_VENDOR_WEBGL;
"#;
let probes = extract_probes(js);
assert!(probes.contains(&ProbeSurface::new("navigator", "userAgent")));
assert!(probes.contains(&ProbeSurface::new("window", "chrome")));
assert!(probes.contains(&ProbeSurface::new("document", "referrer")));
assert!(probes.contains(&ProbeSurface::new("screen", "width")));
assert!(probes.contains(&ProbeSurface::new(
"WebGLRenderingContext",
"UNMASKED_VENDOR_WEBGL"
)));
}
#[test]
fn extract_probes_deduplicates_repeats() {
let js = r#"
navigator.webdriver;
navigator.webdriver;
navigator.webdriver;
"#;
let probes = extract_probes(js);
assert_eq!(
probes.iter().filter(|p| p.property == "webdriver").count(),
1
);
}
#[test]
fn extract_probes_filters_js_reserved_words() {
let js = "navigator.then(()=>{});navigator.toString();navigator.length;";
let probes = extract_probes(js);
assert!(!probes
.iter()
.any(|p| matches!(p.property.as_str(), "then" | "toString" | "length")));
}
#[test]
fn extract_probes_respects_left_word_boundary() {
let js = "xnavigator.foo;navigator.bar;";
let probes = extract_probes(js);
assert!(probes.contains(&ProbeSurface::new("navigator", "bar")));
assert!(!probes.contains(&ProbeSurface::new("navigator", "foo")));
}
#[test]
fn extract_probes_handles_empty_input() {
assert!(extract_probes("").is_empty());
assert!(extract_probes("// just a comment").is_empty());
}
#[test]
fn extract_probes_skips_minified_short_identifiers_correctly() {
let js = "var e=navigator;var t=window;t.chrome;navigator.platform;";
let probes = extract_probes(js);
assert!(probes.contains(&ProbeSurface::new("navigator", "platform")));
assert!(
!probes.contains(&ProbeSurface::new("window", "chrome")),
"aliased access via `t.chrome` should NOT resolve to window.chrome"
);
}
#[test]
fn scrape_diff_returns_new_probes_when_baseline_is_empty() {
let mut s = VendorJsScraper::new();
s.set_baseline("test_vendor", HashSet::new());
let probes_now: HashSet<ProbeSurface> =
extract_probes("navigator.webdriver;navigator.platform;")
.into_iter()
.collect();
let baseline = s.baseline("test_vendor").unwrap().clone();
let new: HashSet<_> = probes_now.difference(&baseline).cloned().collect();
assert_eq!(new.len(), 2);
}
#[test]
fn scrape_diff_detects_added_probes_between_runs() {
let mut s = VendorJsScraper::new();
let v1: HashSet<ProbeSurface> =
extract_probes("navigator.webdriver;").into_iter().collect();
s.set_baseline("v", v1.clone());
let v2: HashSet<ProbeSurface> = extract_probes("navigator.webdriver;navigator.platform;")
.into_iter()
.collect();
let added: Vec<_> = v2.difference(&v1).cloned().collect();
assert_eq!(added.len(), 1);
assert_eq!(added[0].property, "platform");
}
#[test]
fn scrape_diff_detects_removed_probes_between_runs() {
let v1: HashSet<ProbeSurface> =
extract_probes("navigator.webdriver;navigator.userAgent;navigator.platform;")
.into_iter()
.collect();
let v2: HashSet<ProbeSurface> = extract_probes("navigator.webdriver;navigator.platform;")
.into_iter()
.collect();
let removed: Vec<_> = v1.difference(&v2).cloned().collect();
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].property, "userAgent");
}
#[test]
fn scrape_report_summary_lists_new_probes() {
let r = ScrapeReport {
vendor: "demo".into(),
urls_fetched: vec!["https://example.com/api.js".into()],
bytes_total: 1234,
probes_total: 1,
probes_new: vec![ProbeSurface::new("navigator", "newProbe")],
probes_removed: vec![],
probes_stable: vec![],
};
let s = r.render_summary();
assert!(s.contains("vendor: demo"));
assert!(s.contains("new probes: 1"));
assert!(s.contains("navigator.newProbe"));
}
#[test]
fn scrape_report_summary_lists_removed_probes() {
let r = ScrapeReport {
vendor: "demo".into(),
urls_fetched: vec![],
bytes_total: 0,
probes_total: 0,
probes_new: vec![],
probes_removed: vec![ProbeSurface::new("window", "deprecated")],
probes_stable: vec![],
};
let s = r.render_summary();
assert!(s.contains("window.deprecated"));
assert!(s.contains("removed:"));
}
#[test]
fn bundled_targets_cover_the_four_most_deployed_vendors() {
let targets = bundled_targets();
let names: Vec<&str> = targets.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"cloudflare-turnstile"));
assert!(names.contains(&"hcaptcha"));
assert!(names.contains(&"recaptcha-v2"));
assert!(names.contains(&"datadome"));
}
#[test]
fn every_bundled_target_has_at_least_one_url() {
for t in bundled_targets() {
assert!(!t.urls.is_empty(), "{} has no URLs", t.name);
}
}
#[test]
fn probe_surface_displays_as_dotted_path() {
let p = ProbeSurface::new("navigator", "webdriver");
assert_eq!(p.to_string(), "navigator.webdriver");
}
}