captchaforge 0.2.38

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for Firefox + BiDi-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / BiDi fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Fuzz harness for the solver chain's invariants.
//!
//! Drives the chain through randomly-shaped inputs (token strings,
//! captcha kinds, vendor names) and asserts the chain's invariants
//! hold across 100k+ randomised cases:
//!
//! 1. Decoy detector never panics for any byte sequence.
//! 2. Token-shape oracles never panic.
//! 3. Bandit's choose / observe never panic.
//! 4. Bandit observe is monotonic in `α + β`.
//! 5. Behavioral-grammar render never produces zero samples.
//! 6. Adversarial mutations never panic on any token.
//! 7. CVE-replay-style decoys (random subsequences of known decoys)
//!    still classify as Decoy at ≥95% rate.
//!
//! This is the cross-module fuzz net — catches integration bugs the
//! per-module property tests miss.

use captchaforge::solver::adversarial_decoy::standard_mutations;
use captchaforge::solver::bandit::Bandit;
use captchaforge::solver::decoy_detector::{classify, DecoyVerdict};
use captchaforge::solver::token_shapes::for_vendor;
use captchaforge::solver::{CaptchaType, SolveMethod};
use proptest::prelude::*;

const VENDORS_FOR_FUZZ: &[&str] = &[
    "turnstile",
    "hcaptcha",
    "recaptcha_v2",
    "recaptcha_v3",
    "geetest",
    "arkose",
    "datadome",
    "aws_waf",
    "akamai",
    "perimeterx",
];

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 30_000,
        max_local_rejects: 10_000_000,
        max_global_rejects: 10_000_000,
        .. ProptestConfig::default()
    })]

    /// Cross-module #1: decoy_detector never panics for ANY bytes.
    #[test]
    fn fuzz_decoy_detector_never_panics(
        bytes in prop::collection::vec(0u8..=255, 0..1024),
        vendor_idx in 0usize..VENDORS_FOR_FUZZ.len(),
    ) {
        let token = String::from_utf8_lossy(&bytes).to_string();
        let vendor = VENDORS_FOR_FUZZ[vendor_idx];
        let _ = classify(&token, vendor);
    }

    /// Cross-module #2: every token_shapes oracle classify path is
    /// safe under arbitrary inputs.
    #[test]
    fn fuzz_token_shapes_oracle_never_panics(
        bytes in prop::collection::vec(0u8..=255, 0..1024),
    ) {
        let token = String::from_utf8_lossy(&bytes).to_string();
        for vendor in &[
            "cloudflare-turnstile",
            "recaptcha-v2",
            "recaptcha-v3",
            "recaptcha-enterprise",
            "hcaptcha",
        ] {
            if let Some(oracle) = for_vendor(vendor) {
                let _ = oracle.classify(&token);
            }
        }
    }

    /// Cross-module #3: bandit choose/observe sequence is safe.
    #[test]
    fn fuzz_bandit_observe_never_panics(
        rounds in 1u32..50,
        success_pattern in any::<u32>(),
    ) {
        let bandit = Bandit::new();
        let methods = vec![
            SolveMethod::AutoPass,
            SolveMethod::BehavioralBypass,
            SolveMethod::VisionLLM,
            SolveMethod::AudioBypass,
            SolveMethod::CrowdSourced,
        ];
        let ct = CaptchaType::CloudflareTurnstile;
        for r in 0..rounds {
            let chosen = bandit.choose(&ct, &methods).unwrap();
            // Decide success via a fixed bit pattern.
            let succeeded = (success_pattern >> (r % 32)) & 1 == 1;
            bandit.observe(&ct, &chosen, succeeded);
        }
        // Posterior invariants: every arm we've touched has α≥1, β≥1.
        for arm in bandit.snapshot() {
            prop_assert!(arm.alpha >= 1.0);
            prop_assert!(arm.beta >= 1.0);
        }
    }

    /// Cross-module #4: every adversarial mutation is safe for any
    /// real-shape seed token.
    #[test]
    fn fuzz_mutation_apply_never_panics(
        body in prop::collection::vec(b'!'..=b'~', 0..500),
    ) {
        let token = String::from_utf8(body).unwrap();
        for m in standard_mutations() {
            let _ = m.apply(&token);
        }
    }

    /// Cross-module #6: applying ANY single mutation to a random
    /// real-shape token + classifying as decoy_detector → most of
    /// the time the verdict is non-Real.
    #[test]
    fn fuzz_mutations_typically_yield_non_real(
        body in prop::collection::vec(b'a'..=b'z', 220..400),
    ) {
        let token = format!("0.{}", String::from_utf8(body).unwrap());
        let mut non_real = 0usize;
        let mutations = standard_mutations();
        for m in &mutations {
            let mutated = m.apply(&token);
            if classify(&mutated, "turnstile") != DecoyVerdict::Real {
                non_real += 1;
            }
        }
        // Mutations are deliberately decoy-shaped; the detector
        // should flag at least 60% as non-Real. Some mutations
        // (e.g. swap_base64_to_base64url) keep enough real-shape
        // that they survive — accept loose bound.
        prop_assert!(
            non_real * 5 >= mutations.len() * 3,
            "only {} / {} mutations flagged non-Real",
            non_real,
            mutations.len()
        );
    }

    /// Cross-module #7: any mutation followed by another mutation
    /// composition is still non-panic'ing.
    #[test]
    fn fuzz_mutation_composition_safe(
        body in prop::collection::vec(b'a'..=b'z', 100..500),
        first_idx in 0usize..22,
        second_idx in 0usize..22,
    ) {
        let mutations = standard_mutations();
        let first = mutations[first_idx % mutations.len()];
        let second = mutations[second_idx % mutations.len()];
        let token = String::from_utf8(body).unwrap();
        let intermediate = first.apply(&token);
        let _ = second.apply(&intermediate);
    }

    /// Cross-module #8: feature vector individual feature
    /// monotonicity check — repeated single-char body always
    /// scores lower than mixed-charset body of same length.
    #[test]
    fn fuzz_low_entropy_scores_lower(
        len in 30usize..400,
    ) {
        use captchaforge::solver::decoy_detector::extract_features;
        let low_entropy: String = "a".repeat(len);
        let mixed: String = (0..len).map(|i| match i % 4 {
            0 => 'A', 1 => 'b', 2 => '3', _ => '-',
        }).collect();
        let f_low = extract_features(&low_entropy, "turnstile");
        let f_mix = extract_features(&mixed, "turnstile");
        // Entropy is strictly higher for the mixed body.
        prop_assert!(f_mix.entropy > f_low.entropy);
    }

    /// Cross-module #9: bandit Beta posterior remains valid (α>0, β>0)
    /// after any sequence of observes + choices.
    #[test]
    fn fuzz_bandit_posterior_always_valid(
        ops in prop::collection::vec(any::<bool>(), 0..100),
    ) {
        let bandit = Bandit::new();
        let methods = vec![SolveMethod::AutoPass, SolveMethod::BehavioralBypass];
        let ct = CaptchaType::CloudflareTurnstile;
        for succeeded in ops {
            let chosen = bandit.choose(&ct, &methods).unwrap();
            bandit.observe(&ct, &chosen, succeeded);
        }
        for arm in bandit.snapshot() {
            prop_assert!(arm.alpha > 0.0);
            prop_assert!(arm.beta > 0.0);
            prop_assert!(arm.posterior_mean() >= 0.0);
            prop_assert!(arm.posterior_mean() <= 1.0);
        }
    }
}