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
//! Imperva (Incapsula / Advanced Bot Protection) dedicated solver.
//!
//! Imperva fronts a large enterprise footprint and ships in two
//! generations whose clearance signal differs:
//!
//! 1. **Classic Incapsula**: a `_Incapsula_Resource` JS challenge runs,
//!    POSTs to `/_Incapsula_Resource?...`, and on success sets the
//!    `visid_incap_<siteid>` (stable visitor id) + `incap_ses_<n>_<siteid>`
//!    (session) cookies, then reloads to the real content.
//! 2. **Advanced Bot Protection (ABP)**: a heavily-obfuscated sensor
//!    posts telemetry and, on success, sets the long-form **`reese84`**
//!    token cookie. `reese84` is the actual gate for modern Imperva.
//!
//! A hard block renders the classic interstitial body
//! `Request unsuccessful. Incapsula incident ID: <id>`.
//!
//! Like the Akamai/DataDome solvers, this does NOT reverse-engineer the
//! sensor format (a moving target Imperva actively defends). It relies on a
//! coherent stealth fingerprint making Imperva's own JS measurements pass,
//! then waits for the clearance cookie and harvests it. It reports faithfully:
//! `Valid` only when a real clearance token is present, `blocked` on the
//! incident page, otherwise it keeps waiting, it never claims a pass it
//! cannot see.
//!
//! # Pass criteria
//!
//! `reese84` present and long-form (ABP), OR, for classic Incapsula, the
//! `_Incapsula_Resource` challenge is gone AND an `incap_ses_*` session
//! cookie is set. `visid_incap_*` alone is NOT sufficient: it is assigned on
//! the very first visit, before the challenge is solved.

use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};

/// Default total wait budget: Imperva's challenge POST typically lands within
/// 3–7s; 20s matches the Akamai/DataDome interstitial solvers.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// Minimum length for a `reese84` value to count as a real ABP token. Genuine
/// `reese84` cookies are long base64/JSON-ish payloads (well over 100 chars);
/// a short value is a pre-sensor placeholder.
const REESE84_MIN_LEN: usize = 60;

/// Minimum length for an `incap_ses_*` value to count as a real session token
/// (genuine values are ~40+ char base64).
const INCAP_SES_MIN_LEN: usize = 20;

/// Imperva (Incapsula / ABP) interstitial + cookie-pass solver.
pub struct ImpervaSolver {
    max_wait_ms: u64,
}

impl Default for ImpervaSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl ImpervaSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

/// Clearance classification for an Imperva session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImpervaClearance {
    /// No clearance cookie present.
    Missing,
    /// A cookie is present but in pre-sensor / placeholder shape.
    Pending,
    /// A real clearance token is present. Imperva accepted the session.
    Valid,
}

/// Pure classifier for Imperva clearance, given the `reese84` (ABP) value and
/// the `incap_ses_*` (classic) value plus whether the `_Incapsula_Resource`
/// challenge is still present.
///
/// Decision (most-authoritative first):
///   - `reese84` long-form ⇒ `Valid` (modern ABP gate).
///   - else, classic pass: challenge gone AND `incap_ses_*` set ⇒ `Valid`.
///   - else, any cookie present but not yet qualifying ⇒ `Pending`.
///   - else ⇒ `Missing`.
///
/// `visid_incap` is deliberately NOT a pass signal, it is assigned on first
/// visit before the challenge is solved, so trusting it would report a pass
/// that never happened.
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::imperva::{classify_imperva, ImpervaClearance};
///
/// // Modern ABP: a long reese84 token is a pass regardless of challenge state.
/// let reese = "A1b2C3d4".repeat(20);
/// assert_eq!(classify_imperva(&reese, "", true), ImpervaClearance::Valid);
/// // Classic: challenge gone + a real incap_ses ⇒ pass.
/// assert_eq!(classify_imperva("", "Tfje92kfASDFkjasdf9012", false), ImpervaClearance::Valid);
/// // Classic: incap_ses set but challenge STILL present ⇒ not yet.
/// assert_eq!(classify_imperva("", "Tfje92kfASDFkjasdf9012", true), ImpervaClearance::Pending);
/// // Nothing ⇒ missing.
/// assert_eq!(classify_imperva("", "", true), ImpervaClearance::Missing);
/// ```
pub fn classify_imperva(
    reese84: &str,
    incap_ses: &str,
    challenge_present: bool,
) -> ImpervaClearance {
    let reese = reese84.trim().trim_matches('"');
    let ses = incap_ses.trim().trim_matches('"');

    if reese.len() >= REESE84_MIN_LEN {
        return ImpervaClearance::Valid;
    }
    if !challenge_present && ses.len() >= INCAP_SES_MIN_LEN {
        return ImpervaClearance::Valid;
    }
    if reese.is_empty() && ses.is_empty() {
        return ImpervaClearance::Missing;
    }
    ImpervaClearance::Pending
}

/// JS that probes the page for Imperva signals. Returns raw signals; Rust
/// decides the phase via [`classify_imperva`] (keeps the decision pure /
/// unit-tested rather than buried in a JS string).
///
/// Fields: `blocked` (incident page), `challenge` (`_Incapsula_Resource`
/// present), `reese84`, `incapSes`, `visid`.
const SIGNAL_PROBE_JS: &str = r#"
(() => {
    const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
    const blocked = body.includes('request unsuccessful') && body.includes('incapsula incident id');
    let challenge = false;
    try {
        challenge =
            !!document.querySelector('iframe[src*="_Incapsula_Resource"], script[src*="_Incapsula_Resource"], [src*="incapsula.com"]') ||
            typeof window._Incapsula_Resource !== 'undefined' ||
            typeof window.incap_ses !== 'undefined';
    } catch (e) { challenge = false; }
    let reese = '', incapSes = '', visid = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        const eq = t.indexOf('=');
        if (eq < 0) continue;
        const name = t.slice(0, eq);
        const val = t.slice(eq + 1);
        if (name === 'reese84') reese = val;
        else if (name.indexOf('incap_ses_') === 0) incapSes = val;
        else if (name.indexOf('visid_incap_') === 0) visid = val;
    }
    return { blocked: blocked, challenge: challenge, reese84: reese, incapSes: incapSes, visid: visid };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct ImpervaSignals {
    #[serde(default)]
    blocked: bool,
    #[serde(default)]
    challenge: bool,
    #[serde(default)]
    reese84: String,
    #[serde(default, rename = "incapSes")]
    incap_ses: String,
    #[serde(default)]
    visid: String,
}

#[async_trait]
impl CaptchaSolver for ImpervaSolver {
    fn name(&self) -> &'static str {
        "ImpervaSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::AutoPass
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Detected as Custom("imperva_incapsula") or Custom("incapsula_protect")
        // by the bundled community rules.
        matches!(kind, DetectedCaptcha::Custom(s)
            if s == "imperva_incapsula" || s == "incapsula_protect")
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let deadline = t0 + Duration::from_millis(self.max_wait_ms);

        loop {
            let raw = page
                .evaluate(SIGNAL_PROBE_JS)
                .await
                .map_err(|e| anyhow!("Imperva probe failed: {e}"))?;
            let sig: ImpervaSignals = raw.into_value().unwrap_or(ImpervaSignals {
                blocked: false,
                challenge: true,
                reese84: String::new(),
                incap_ses: String::new(),
                visid: String::new(),
            });
            let _ = &sig.visid; // captured for completeness; not a pass signal.

            if sig.blocked {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }

            if classify_imperva(&sig.reese84, &sig.incap_ses, sig.challenge)
                == ImpervaClearance::Valid
            {
                let cookies = crate::cookies::capture_from_page(page)
                    .await
                    .unwrap_or_default();
                return Ok(CaptchaSolveResult {
                    solution: "imperva:cookie".into(),
                    confidence: 1.0,
                    method: SolveMethod::AutoPass,
                    time_ms: t0.elapsed().as_millis() as u64,
                    success: true,
                    screenshot: None,
                    cookies,
                    verified_outcome: None,
                });
            }

            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_and_builder() {
        assert_eq!(ImpervaSolver::new().max_wait_ms, 20_000);
        assert_eq!(
            ImpervaSolver::new().with_max_wait_ms(8_000).max_wait_ms,
            8_000
        );
    }

    #[test]
    fn name_method_stable() {
        let s = ImpervaSolver::new();
        assert_eq!(s.name(), "ImpervaSolver");
        assert_eq!(s.method(), SolveMethod::AutoPass);
    }

    #[test]
    fn supports_only_imperva_custom_kinds() {
        let s = ImpervaSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("imperva_incapsula".into())));
        assert!(s.supports(&DetectedCaptcha::Custom("incapsula_protect".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("datadome".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("kasada".into())));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::SliderCaptcha));
    }

    #[test]
    fn reese84_long_is_valid_even_during_challenge() {
        // Modern ABP: the token is the gate; challenge-script presence is moot.
        let reese = "A1b2C3d4".repeat(20); // 160 chars
        assert_eq!(classify_imperva(&reese, "", true), ImpervaClearance::Valid);
    }

    #[test]
    fn reese84_short_is_pending() {
        assert_eq!(
            classify_imperva("short", "", true),
            ImpervaClearance::Pending
        );
    }

    #[test]
    fn classic_pass_requires_challenge_gone_and_session() {
        let ses = "TfjE92kfASdfKJasdf9012xyz"; // 25 chars
                                               // Challenge gone + session set ⇒ pass.
        assert_eq!(classify_imperva("", ses, false), ImpervaClearance::Valid);
        // Session set but challenge STILL present ⇒ not yet.
        assert_eq!(classify_imperva("", ses, true), ImpervaClearance::Pending);
    }

    #[test]
    fn short_session_is_not_a_pass() {
        // A too-short incap_ses (placeholder) does not clear even with no challenge.
        assert_eq!(
            classify_imperva("", "abc", false),
            ImpervaClearance::Pending
        );
    }

    #[test]
    fn nothing_present_is_missing() {
        assert_eq!(classify_imperva("", "", true), ImpervaClearance::Missing);
        assert_eq!(classify_imperva("", "", false), ImpervaClearance::Missing);
    }

    #[test]
    fn quoted_values_are_trimmed() {
        let reese = format!("\"{}\"", "Q1w2E3r4".repeat(20));
        assert_eq!(classify_imperva(&reese, "", true), ImpervaClearance::Valid);
    }

    #[test]
    fn signal_probe_js_covers_documented_signals() {
        for needle in [
            "incapsula incident id",
            "_incapsula_resource",
            "reese84",
            "incap_ses_",
            "visid_incap_",
        ] {
            assert!(
                SIGNAL_PROBE_JS.to_lowercase().contains(needle),
                "SIGNAL_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn signal_probe_js_is_bracket_balanced() {
        let mut depth = 0i32;
        for c in SIGNAL_PROBE_JS.chars() {
            match c {
                '(' | '{' | '[' => depth += 1,
                ')' | '}' | ']' => depth -= 1,
                _ => {}
            }
            assert!(depth >= 0);
        }
        assert_eq!(depth, 0);
    }
}