use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FeatureVector {
pub entropy: f32,
pub ks_uniform: f32,
pub markov: f32,
pub chi_sq_uniform: f32,
pub run_length: f32,
pub compressibility: f32,
pub ascii_concentration: f32,
pub dot_segment_cv: f32,
pub hex_ratio: f32,
pub bigram_coverage: f32,
pub length_match: f32,
}
impl FeatureVector {
pub fn score(&self) -> f32 {
let s = 0.18 * self.entropy
+ 0.12 * self.ks_uniform
+ 0.14 * self.markov
+ 0.08 * self.chi_sq_uniform
+ 0.07 * self.run_length
+ 0.08 * self.compressibility
+ 0.05 * self.ascii_concentration
+ 0.06 * self.dot_segment_cv
+ 0.04 * self.hex_ratio
+ 0.10 * self.bigram_coverage
+ 0.08 * self.length_match;
s.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecoyVerdict {
Real,
Borderline,
Decoy,
}
#[derive(Debug, Clone, Copy)]
pub struct VendorProfile {
pub vendor: &'static str,
pub mean_length: f32,
pub min_length: usize,
}
pub const VENDOR_PROFILES: &[VendorProfile] = &[
VendorProfile {
vendor: "turnstile",
mean_length: 340.0,
min_length: 200,
},
VendorProfile {
vendor: "hcaptcha",
mean_length: 350.0,
min_length: 80,
},
VendorProfile {
vendor: "recaptcha-v2",
mean_length: 1500.0,
min_length: 200,
},
VendorProfile {
vendor: "recaptcha-v3",
mean_length: 750.0,
min_length: 200,
},
VendorProfile {
vendor: "recaptcha-enterprise",
mean_length: 1500.0,
min_length: 200,
},
VendorProfile {
vendor: "geetest",
mean_length: 200.0,
min_length: 60,
},
VendorProfile {
vendor: "arkose",
mean_length: 240.0,
min_length: 60,
},
VendorProfile {
vendor: "datadome",
mean_length: 220.0,
min_length: 40,
},
VendorProfile {
vendor: "aws_waf",
mean_length: 280.0,
min_length: 60,
},
VendorProfile {
vendor: "akamai",
mean_length: 320.0,
min_length: 80,
},
VendorProfile {
vendor: "perimeterx",
mean_length: 200.0,
min_length: 60,
},
];
pub fn profile_for(vendor: &str) -> Option<&'static VendorProfile> {
VENDOR_PROFILES.iter().find(|p| p.vendor == vendor)
}
pub fn extract_features(token: &str, vendor: &str) -> FeatureVector {
let bytes = token.as_bytes();
let len = bytes.len() as f32;
let entropy = shannon_entropy(bytes) / 8.0; let ks_uniform = 1.0 - ks_d_against_uniform_base64(bytes);
let markov = markov_transition_score(bytes);
let chi_sq_uniform = chi_squared_normalised(bytes);
let run_length = run_length_score(bytes);
let compressibility = compressibility_score(bytes);
let ascii_concentration = ascii_concentration(bytes);
let dot_segment_cv = dot_segment_variation(token);
let hex_ratio = hex_ratio(bytes);
let bigram_coverage = bigram_coverage(bytes);
let length_match = if let Some(p) = profile_for(vendor) {
if len <= 0.0 {
0.0
} else {
(1.0 - (len - p.mean_length).abs() / p.mean_length).clamp(0.0, 1.0)
}
} else {
0.5
};
FeatureVector {
entropy,
ks_uniform,
markov,
chi_sq_uniform,
run_length,
compressibility,
ascii_concentration,
dot_segment_cv,
hex_ratio,
bigram_coverage,
length_match,
}
}
pub fn classify(token: &str, vendor: &str) -> DecoyVerdict {
if token.is_empty() {
return DecoyVerdict::Decoy;
}
if let Some(p) = profile_for(vendor) {
if token.len() < p.min_length {
return DecoyVerdict::Decoy;
}
}
let dot_count = token.bytes().filter(|b| *b == b'.').count();
let too_many_dots = match vendor {
"recaptcha_v2" | "recaptcha_v3" | "recaptcha-v2" | "recaptcha-v3" => dot_count > 4,
"hcaptcha" => dot_count > 4,
_ => false,
};
if too_many_dots {
return DecoyVerdict::Decoy;
}
let features = extract_features(token, vendor);
let score = features.score();
if score >= 0.70 {
DecoyVerdict::Real
} else if score >= 0.40 {
DecoyVerdict::Borderline
} else {
DecoyVerdict::Decoy
}
}
#[path = "decoy_detector/features.rs"]
mod features;
pub use features::*;
pub const DECOY_DETECTOR_TODO: &[&str] = &[
"Replace hand-tuned linear weights with logistic-regression \
weights fit from a labelled corpus (build `tests/decoy_corpus/` \
with 10k labelled real + decoy tokens).",
"Replace 4-class Markov transition matrix with 16-class \
transition matrix sampled from real corpus per vendor.",
"Replace 32-bigram coverage list with vendor-specific top-N \
bigram tables.",
"Add a 12th feature: position-weighted-bigram score (real tokens \
have stable prefix shapes vs random middle).",
"Add 13th feature: vendor-prefix conformance (Turnstile `0.`/`1.`, \
hCaptcha `P0_`/`P1_`).",
"Add 14th feature: post-decode JSON parseability (some vendor \
tokens are JWT-style base64-encoded JSON).",
"Train ROC-AUC ≥ 0.99 across vendors against the labelled corpus.",
"Add a calibration test: detector verdict ↔ live-vendor accept \
rate alignment > 0.95 (when bench is wired against real vendor).",
];
#[cfg(test)]
#[path = "decoy_detector/tests.rs"]
mod tests;