#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# -:-:-:-:-:-:-::-:-:#
#    XSRF Probe     #
# -:-:-:-:-:-:-::-:-:#

# Author: 0xInfection
# This module requires XSRFProbe
# https://github.com/0xInfection/XSRFProbe

from rapidfuzz.distance import Levenshtein
import itertools
import logging

from xsrfprobe.modules.Entropy import calcEntropy
from xsrfprobe.core.utils import sameSequence
from xsrfprobe.files import discovered
from xsrfprobe.core.logger import VulnLogger, NovulLogger, PROGRESS, phaseHeader, testProgress

logger = logging.getLogger("Analysis")

def Analysis():
    """
    Observe and analyse the patterns in which CSRF tokens
    are generated by the server.
    """
    all_tokens = list(discovered.ANTI_CSRF_TOKENS) + list(discovered.TOKEN_SAMPLES)
    if len(all_tokens) <= 1:
        return

    by_name: dict[str, list[str]] = {}
    # Remember a representative endpoint per token field so the finding is
    # attributed to a real URL instead of a synthetic "Analysis" placeholder.
    url_by_name: dict[str, str] = {}
    for tk in all_tokens:
        if not tk.token:
            continue
        values = by_name.setdefault(tk.name, [])
        if tk.token not in values:
            values.append(tk.token)
        if tk.url and tk.name not in url_by_name:
            url_by_name[tk.name] = tk.url

    comparable = {name: vals for name, vals in by_name.items() if len(vals) >= 2}

    phaseHeader(logger, "Analysis")
    logger.log(PROGRESS, "A total of %s token record(s) across %s distinct value(s) were discovered",
               len(all_tokens), sum(len(v) for v in by_name.values()))

    if not comparable:
        logger.log(PROGRESS,
            "Insufficient distinct token samples for predictability analysis "
            "(need >=2 distinct values generated by the same token field)."
        )
        return

    for name, values in comparable.items():
        finding_url = url_by_name.get(name, "Analysis")
        n_samples = len(values)
        lengths = {len(v) for v in values}
        entropies = [calcEntropy(v) for v in values]
        avg_entropy = sum(entropies) / len(entropies)

        dists = [Levenshtein.normalized_distance(a, b)
                 for a, b in itertools.combinations(values, 2)]
        min_dist = min(dists)
        avg_dist = sum(dists) / len(dists)
        max_dist = max(dists)

        static = values[0]
        for v in values[1:]:
            static = sameSequence(static, v)
            if not static:
                break
        static_len = len(static)
        avg_len = sum(len(v) for v in values) / n_samples
        dynamic_len = avg_len - static_len

        length_desc = (f"{next(iter(lengths))} chars" if len(lengths) == 1
                       else f"{min(lengths)}-{max(lengths)} chars (varies)")

        detail = (
            f"[i] Field: {name}\n"
            f"[i] Distinct samples analysed: {n_samples}\n"
            f"[i] Token length: {length_desc}\n"
            f"[i] Avg Shannon entropy: {avg_entropy:.2f} bits/char\n"
            f"[i] Pairwise edit distance (normalised): min {min_dist:.2f}, avg {avg_dist:.2f}\n"
            f"[i] Shared static component: '{static}' ({static_len} chars)\n"
            f"[i] Avg dynamic length: {dynamic_len:.1f} chars"
        )

        with testProgress(logger, "A1", f"Analysing token field: '{name}'") as tp:
            # Verbose per-sample breakdown (visible at -v / INFO).
            for i, (val, ent) in enumerate(zip(values, entropies), 1):
                logger.info("    #%d  value=%s", i, val)
                logger.info("        length=%d chars | Shannon entropy=%.3f bits/char (%.1f bits total)",
                            len(val), ent, ent * len(val))
            logger.info("[Analysis] Pairwise normalised edit distance over %d pair(s): "
                        "min=%.3f, avg=%.3f, max=%.3f (1.0 = completely different)",
                        len(dists), min_dist, avg_dist, max_dist)
            if min_dist < 0.5 or dynamic_len <= 6:
                tp["status"] = "WEAK"
            else:
                tp["status"] = "strong"

        logger.log(PROGRESS, "[Analysis] (%s) Length: %s | avg entropy: %.3f bits/char",
                   name, length_desc, avg_entropy)

        if min_dist < 0.5 or dynamic_len <= 6:
            reasons = []
            if min_dist < 0.5:
                reasons.append(f"min edit distance {min_dist:.2f} < 0.50 (tokens too similar)")
            if dynamic_len <= 6:
                reasons.append(f"dynamic component ~{dynamic_len:.0f} chars <= 6 (brute-forceable)")
            logger.warning("[Analysis] Token field '%s' looks predictable/forgeable: %s.",
                           name, "; ".join(reasons))
            VulnLogger(
                finding_url,
                f"Anti-CSRF token field '{name}' appears weak/forgeable: low inter-token "
                f"variance (min normalised edit distance {min_dist:.2f}) or small dynamic "
                f"component (~{dynamic_len:.0f} chars).",
                detail,
                test_id="A1",
            )
        else:
            logger.log(PROGRESS, "[Analysis] Token field '%s' appears strong: min edit distance "
                        "%.2f >= 0.50 and dynamic component ~%.0f chars > 6.",
                        name, min_dist, dynamic_len)
            NovulLogger(
                finding_url,
                f"Anti-CSRF token field '{name}' appears strong: {n_samples} distinct "
                f"samples, avg entropy {avg_entropy:.2f} bits/char, min normalised edit "
                f"distance {min_dist:.2f}, dynamic component ~{dynamic_len:.0f} chars.",
                test_id="A1",
            )

    logger.log(PROGRESS, "Post-Scan Analysis Completed!")
