#![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 {
let host_label = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| "(unparseable)".to_string());
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!(host = %host_label, error = %e, "vendor_scraper: body read failed");
}
},
Err(e) => {
tracing::warn!(host = %host_label, 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)]
#[path = "vendor_scraper/tests.rs"]
mod tests;