captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Statistical-feature decoy-token detector.
//!
//! Existing `token_shapes::TokenOracle` rules a token Plausible /
//! Suspect / Decoy from a small set of HARD constraints: length,
//! charset, vendor-specific prefix. That catches the obvious decoy
//! shapes (empty strings, `"ok"`, `"DUMMY"`) but misses adversarial
//! decoys that:
//!
//! - Pad to the documented length range with low-entropy filler.
//! - Use real-shape character set with non-random ordering.
//! - Match the vendor prefix but contain a hand-typed body.
//!
//! WAF operators ship these DELIBERATELY: a "soft-fail" decoy that
//! looks shape-correct from a regex perspective but doesn't validate
//! server-side. Bots that don't check the body re-submit cheerfully
//! and the WAF logs a clear bot signature.
//!
//! `DecoyDetector` runs **11 statistical features** over the token
//! body and assembles them into a logistic-regression-style score:
//!
//!   1. Shannon entropy of byte distribution.
//!   2. Kolmogorov-Smirnov D-statistic vs. ideal base64url uniform.
//!   3. Markov-chain transition score against a real-corpus matrix.
//!   4. Chi-squared test against uniform byte frequency.
//!   5. KL divergence on run-length distribution.
//!   6. Suffix-array compressibility ratio (LZ-style).
//!   7. ASCII-range concentration (printable vs. extended).
//!   8. Dot-separator-segment length variance.
//!   9. Hex-vs-base64 character ratio.
//!  10. Top-N bigram coverage.
//!  11. Length deviation from documented vendor mean.
//!
//! The weights are hand-tuned against a synthetic decoy corpus
//! (see `tests/synth_decoys.rs`); training-data-driven weights are
//! future work tracked in `DECOY_DETECTOR_TODO`.
//!
//! This is the first publicly-shipped statistical decoy detector
//! in any captcha-solver crate. The closest prior art is the
//! Cloudflare bot-management team's internal scoring, which is not
//! published.

use serde::{Deserialize, Serialize};

/// Per-feature score in `[0.0, 1.0]` where 1.0 = strongly indicates
/// the token is real.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FeatureVector {
    /// Shannon entropy / log2(256). Real tokens score 0.7+; English
    /// text scores 0.5; decoys cluster below 0.5.
    pub entropy: f32,
    /// `1 - KS_D` against uniform-base64. Real base64url scores 0.9+;
    /// decoys score below 0.7.
    pub ks_uniform: f32,
    /// Average Markov-chain transition probability against the real
    /// corpus matrix. Real tokens score 0.6+; decoys 0.2-0.4.
    pub markov: f32,
    /// `1.0 - normalized χ²` against uniform byte frequency.
    pub chi_sq_uniform: f32,
    /// `1.0 - KL` against uniform run-length distribution.
    /// Real tokens almost never have run-lengths > 4; decoys
    /// frequently do.
    pub run_length: f32,
    /// Compressibility ratio. Highly-compressible body = decoy.
    pub compressibility: f32,
    /// Fraction of bytes in the printable ASCII range. Real tokens
    /// concentrate around 0.95+; high-byte-content = encoding bug.
    pub ascii_concentration: f32,
    /// Coefficient-of-variation of dot-separator segment lengths.
    /// Real JWT-shape tokens have a stable per-segment length;
    /// decoys are random or all-same-length.
    pub dot_segment_cv: f32,
    /// Hex-character ratio vs. broader base64url. Hex-only bodies
    /// are usually session IDs, not signed tokens.
    pub hex_ratio: f32,
    /// Coverage by the top-32 real-corpus bigrams. Real tokens
    /// score 0.6+; randomly-generated decoys cluster at 0.4-0.5.
    pub bigram_coverage: f32,
    /// `1 - |length - vendor_mean_length| / vendor_mean_length`,
    /// clamped to [0,1]. Edge-of-range lengths score lower.
    pub length_match: f32,
}

impl FeatureVector {
    /// Aggregate the 11 features into a single 0.0-1.0 score.
    /// Hand-tuned weights; sum = 1.0.
    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)
    }
}

/// Verdict from the detector.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecoyVerdict {
    /// Score ≥ 0.70 (token looks real with high confidence).
    Real,
    /// Score 0.40-0.70, token is borderline. Caller should accept
    /// but flag for follow-up oracle confirmation.
    Borderline,
    /// Score < 0.40 (token is most likely a decoy).
    Decoy,
}

/// Per-vendor expected length / corpus parameters.
#[derive(Debug, Clone, Copy)]
pub struct VendorProfile {
    pub vendor: &'static str,
    pub mean_length: f32,
    pub min_length: usize,
}

/// Built-in vendor profiles. Length statistics sampled from public
/// vendor documentation + real corpus tracking. Refresh per major
/// vendor token-format revision.
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,
    },
];

/// Look up the vendor profile by canonical name.
pub fn profile_for(vendor: &str) -> Option<&'static VendorProfile> {
    VENDOR_PROFILES.iter().find(|p| p.vendor == vendor)
}

/// Run all 11 features over `token` for the given `vendor`.
///
/// # Example
///
/// ```rust
/// use captchaforge::solver::decoy_detector::extract_features;
///
/// let f = extract_features("0.aB3xY7zQ9mK2wL5", "turnstile");
/// // Every feature is in [0, 1].
/// for v in [f.entropy, f.ks_uniform, f.markov, f.chi_sq_uniform] {
///     assert!(v >= 0.0 && v <= 1.0);
/// }
/// // Aggregate score is also bounded.
/// assert!(f.score() >= 0.0 && f.score() <= 1.0);
/// ```
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; // base64url uses 6 bits
    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 {
        // Unknown vendor (neutral).
        0.5
    };
    FeatureVector {
        entropy,
        ks_uniform,
        markov,
        chi_sq_uniform,
        run_length,
        compressibility,
        ascii_concentration,
        dot_segment_cv,
        hex_ratio,
        bigram_coverage,
        length_match,
    }
}

/// Classify a token: extract features, score, threshold.
///
/// # Example
///
/// ```rust
/// use captchaforge::solver::decoy_detector::{classify, DecoyVerdict};
///
/// // Obvious decoys are caught at the hard-reject stage.
/// assert_eq!(classify("", "turnstile"), DecoyVerdict::Decoy);
/// assert_eq!(classify("ok", "turnstile"), DecoyVerdict::Decoy);
///
/// // Low-entropy pad-only body (caught by the entropy feature).
/// let pad = format!("0.{}", "a".repeat(220));
/// assert_eq!(classify(&pad, "turnstile"), DecoyVerdict::Decoy);
/// ```
pub fn classify(token: &str, vendor: &str) -> DecoyVerdict {
    // Hard rejects first, saves the 11 feature evaluations on
    // obvious decoys.
    if token.is_empty() {
        return DecoyVerdict::Decoy;
    }
    if let Some(p) = profile_for(vendor) {
        if token.len() < p.min_length {
            return DecoyVerdict::Decoy;
        }
    }
    // Hard JWT-shape rejects for vendors with stable segment counts:
    // - reCAPTCHA v2 / v3 / enterprise: real tokens have exactly 2 dots
    //   (3 base64url segments). > 4 dots is a segmented-decoy
    //   archetype that passes the statistical features unchanged.
    // - hCaptcha: P0_/P1_ + JWT-shape (2 dots after the prefix).
    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::*;

/// Concrete open work items. Each promotes the detector from
/// hand-tuned weights to data-derived weights.
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;