"""Credential Scanner — Detect hardcoded credentials and secrets in MCP configs.

Scans source code, configuration files, and MCP server definitions for
hardcoded credentials, API keys, tokens, passwords, and high-entropy strings.
Supports 25+ built-in patterns and allowlist-based false positive suppression.

Features:
- CredentialPattern: name, regex, severity, description (25+ built-in)
- CredentialFinding: matched pattern, file, line, masked value (never exposes secrets)
- CredentialScanner: scan string content, scan file, scan directory
- Entropy-based detection for high-entropy strings
- Allowlist support to suppress false positives
- Format as text, JSON, and SARIF

Zero external dependencies — pure Python stdlib.

Usage:
    from mcpaudit.credential_scanner import CredentialScanner
    scanner = CredentialScanner()
    findings = scanner.scan_string(content, filename="config.json")
    findings = scanner.scan_file(Path("server.py"))
    findings = scanner.scan_directory(Path("."))

CLI hint:
    mcpaudit cred-scan ./my-mcp-server/
    mcpaudit cred-scan config.json --format sarif
"""

from __future__ import annotations

import json
import math
import os
import re
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


# ---------- Constants ----------

class CredSeverity:
    """Severity levels for credential findings."""
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"
    INFO = "INFO"


# ---------- Data Models ----------

@dataclass
class CredentialPattern:
    """A pattern for detecting hardcoded credentials."""

    name: str
    regex: str
    severity: str = CredSeverity.HIGH
    description: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "regex": self.regex,
            "severity": self.severity,
            "description": self.description,
        }


@dataclass
class CredentialFinding:
    """A credential detected in source code or configuration."""

    pattern_name: str
    file: str = ""
    line: int = 0
    masked_value: str = ""
    severity: str = CredSeverity.HIGH
    description: str = ""
    column: int = 0
    match_length: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "pattern_name": self.pattern_name,
            "file": self.file,
            "line": self.line,
            "masked_value": self.masked_value,
            "severity": self.severity,
            "description": self.description,
            "column": self.column,
            "match_length": self.match_length,
        }


@dataclass
class ScanResult:
    """Result of scanning for credentials."""

    findings: list[CredentialFinding] = field(default_factory=list)
    files_scanned: int = 0
    lines_scanned: int = 0
    errors: list[str] = field(default_factory=list)

    @property
    def total_findings(self) -> int:
        return len(self.findings)

    @property
    def severity_counts(self) -> dict[str, int]:
        counts: dict[str, int] = {
            "CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0,
        }
        for f in self.findings:
            if f.severity in counts:
                counts[f.severity] += 1
        return counts

    @property
    def exit_code(self) -> int:
        sc = self.severity_counts
        if sc["CRITICAL"] > 0:
            return 2
        if sc["HIGH"] > 0:
            return 1
        return 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "total_findings": self.total_findings,
            "files_scanned": self.files_scanned,
            "lines_scanned": self.lines_scanned,
            "severity_counts": self.severity_counts,
            "exit_code": self.exit_code,
            "findings": [f.to_dict() for f in self.findings],
            "errors": self.errors,
        }


# ---------- Built-in Patterns ----------

BUILTIN_PATTERNS: list[CredentialPattern] = [
    CredentialPattern(
        name="aws_access_key",
        regex=r"(?:^|[^A-Z0-9])(?:AKIA[0-9A-Z]{16})(?:[^A-Z0-9]|$)",
        severity=CredSeverity.CRITICAL,
        description="AWS Access Key ID",
    ),
    CredentialPattern(
        name="aws_secret_key",
        regex=r"(?:aws_secret_access_key|aws_secret_key|secret_access_key)\s*[=:]\s*['\"]?([A-Za-z0-9/+=]{40})['\"]?",
        severity=CredSeverity.CRITICAL,
        description="AWS Secret Access Key",
    ),
    CredentialPattern(
        name="github_token",
        regex=r"(?:ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36})",
        severity=CredSeverity.CRITICAL,
        description="GitHub Personal Access Token",
    ),
    CredentialPattern(
        name="gitlab_token",
        regex=r"glpat-[A-Za-z0-9\-]{20,}",
        severity=CredSeverity.CRITICAL,
        description="GitLab Personal Access Token",
    ),
    CredentialPattern(
        name="slack_token",
        regex=r"xox[baprs]-[A-Za-z0-9\-]{10,}",
        severity=CredSeverity.CRITICAL,
        description="Slack Token",
    ),
    CredentialPattern(
        name="stripe_secret",
        regex=r"sk_live_[A-Za-z0-9]{20,}",
        severity=CredSeverity.CRITICAL,
        description="Stripe Secret Key",
    ),
    CredentialPattern(
        name="stripe_publishable",
        regex=r"pk_live_[A-Za-z0-9]{20,}",
        severity=CredSeverity.MEDIUM,
        description="Stripe Publishable Key",
    ),
    CredentialPattern(
        name="google_api_key",
        regex=r"AIza[A-Za-z0-9_\-]{35}",
        severity=CredSeverity.HIGH,
        description="Google API Key",
    ),
    CredentialPattern(
        name="heroku_api_key",
        regex=r"(?:heroku_api_key|HEROKU_API_KEY)\s*[=:]\s*['\"]?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})['\"]?",
        severity=CredSeverity.HIGH,
        description="Heroku API Key",
    ),
    CredentialPattern(
        name="generic_password",
        regex=r"(?:password|passwd|pwd)\s*[=:]\s*['\"]([^'\"]{8,})['\"]",
        severity=CredSeverity.HIGH,
        description="Hardcoded password",
    ),
    CredentialPattern(
        name="generic_secret",
        regex=r"(?:secret|secret_key|api_secret)\s*[=:]\s*['\"]([^'\"]{8,})['\"]",
        severity=CredSeverity.HIGH,
        description="Hardcoded secret",
    ),
    CredentialPattern(
        name="generic_api_key",
        regex=r"(?:api_key|apikey|api-key)\s*[=:]\s*['\"]([^'\"]{16,})['\"]",
        severity=CredSeverity.HIGH,
        description="Hardcoded API key",
    ),
    CredentialPattern(
        name="generic_token",
        regex=r"(?:auth_token|access_token|bearer_token)\s*[=:]\s*['\"]([^'\"]{16,})['\"]",
        severity=CredSeverity.HIGH,
        description="Hardcoded auth token",
    ),
    CredentialPattern(
        name="private_key_header",
        regex=r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----",
        severity=CredSeverity.CRITICAL,
        description="Private key",
    ),
    CredentialPattern(
        name="jwt_token",
        regex=r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
        severity=CredSeverity.HIGH,
        description="JSON Web Token",
    ),
    CredentialPattern(
        name="azure_storage_key",
        regex=r"DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{40,}",
        severity=CredSeverity.CRITICAL,
        description="Azure Storage Connection String",
    ),
    CredentialPattern(
        name="sendgrid_api_key",
        regex=r"SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
        severity=CredSeverity.HIGH,
        description="SendGrid API Key",
    ),
    CredentialPattern(
        name="twilio_api_key",
        regex=r"SK[0-9a-fA-F]{32}",
        severity=CredSeverity.HIGH,
        description="Twilio API Key",
    ),
    CredentialPattern(
        name="mailgun_api_key",
        regex=r"key-[0-9a-zA-Z]{32}",
        severity=CredSeverity.HIGH,
        description="Mailgun API Key",
    ),
    CredentialPattern(
        name="square_access_token",
        regex=r"sq0atp-[A-Za-z0-9_-]{22}",
        severity=CredSeverity.HIGH,
        description="Square Access Token",
    ),
    CredentialPattern(
        name="square_oauth_secret",
        regex=r"sq0csp-[A-Za-z0-9_-]{43}",
        severity=CredSeverity.HIGH,
        description="Square OAuth Secret",
    ),
    CredentialPattern(
        name="telegram_bot_token",
        regex=r"[0-9]{8,10}:[A-Za-z0-9_-]{35}",
        severity=CredSeverity.HIGH,
        description="Telegram Bot Token",
    ),
    CredentialPattern(
        name="database_url",
        regex=r"(?:mysql|postgres|postgresql|mongodb|redis)://[^:]+:[^@]+@[^\s'\"]+",
        severity=CredSeverity.CRITICAL,
        description="Database connection URL with credentials",
    ),
    CredentialPattern(
        name="basic_auth_header",
        regex=r"(?:Authorization|authorization)\s*[=:]\s*['\"]Basic\s+[A-Za-z0-9+/=]{10,}['\"]",
        severity=CredSeverity.HIGH,
        description="Basic Auth header with encoded credentials",
    ),
    CredentialPattern(
        name="bearer_auth_header",
        regex=r"(?:Authorization|authorization)\s*[=:]\s*['\"]Bearer\s+[A-Za-z0-9._-]{20,}['\"]",
        severity=CredSeverity.HIGH,
        description="Bearer token in authorization header",
    ),
    CredentialPattern(
        name="npm_token",
        regex=r"//registry\.npmjs\.org/:_authToken=[A-Za-z0-9_-]{36}",
        severity=CredSeverity.HIGH,
        description="NPM auth token",
    ),
    CredentialPattern(
        name="pypi_token",
        regex=r"pypi-[A-Za-z0-9_-]{50,}",
        severity=CredSeverity.HIGH,
        description="PyPI API token",
    ),
]


# ---------- Masking ----------

def mask_value(value: str, visible_chars: int = 4) -> str:
    """Mask a secret value, showing only first few characters.

    Args:
        value: The raw secret value.
        visible_chars: Number of characters to show at start.

    Returns:
        Masked string like "sk_l****" or "****" for short values.
    """
    if not value:
        return "****"
    if len(value) <= visible_chars:
        return "*" * len(value)
    return value[:visible_chars] + "*" * min(len(value) - visible_chars, 20)


# ---------- Entropy ----------

def _shannon_entropy(s: str) -> float:
    """Calculate Shannon entropy of a string.

    Higher entropy suggests a random/secret value.

    Args:
        s: Input string.

    Returns:
        Entropy value in bits per character.
    """
    if not s:
        return 0.0

    counts = Counter(s)
    length = len(s)
    entropy = 0.0

    for count in counts.values():
        if count > 0:
            prob = count / length
            entropy -= prob * math.log2(prob)

    return entropy


def is_high_entropy(s: str, threshold: float = 4.5) -> bool:
    """Check if a string has suspiciously high entropy.

    Args:
        s: Input string to check.
        threshold: Entropy threshold (default 4.5 bits).

    Returns:
        True if entropy exceeds threshold and string is long enough.
    """
    if len(s) < 16:
        return False
    return _shannon_entropy(s) >= threshold


# ---------- Allowlist ----------

@dataclass
class AllowlistEntry:
    """An entry that suppresses a false positive."""

    pattern_name: str = ""
    file_pattern: str = ""
    value_pattern: str = ""
    reason: str = ""

    def matches(self, finding: CredentialFinding) -> bool:
        """Check if this entry matches and should suppress a finding."""
        if self.pattern_name and self.pattern_name != finding.pattern_name:
            return False
        if self.file_pattern and self.file_pattern not in finding.file:
            return False
        if self.value_pattern and self.value_pattern not in finding.masked_value:
            return False
        return True


# ---------- CredentialScanner ----------

_SKIP_EXTENSIONS: set[str] = {
    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg",
    ".woff", ".woff2", ".ttf", ".eot",
    ".zip", ".tar", ".gz", ".bz2", ".xz",
    ".exe", ".dll", ".so", ".dylib",
    ".pyc", ".pyo", ".class",
    ".lock",
}

_SKIP_DIRS: set[str] = {
    ".git", "node_modules", "__pycache__", ".venv", "venv",
    ".mypy_cache", ".pytest_cache", ".tox",
}


class CredentialScanner:
    """Scans source code and configs for hardcoded credentials.

    Uses regex-based pattern matching and entropy analysis to detect
    secrets. Supports an allowlist to suppress known false positives.
    """

    def __init__(
        self,
        patterns: list[CredentialPattern] | None = None,
        allowlist: list[AllowlistEntry] | None = None,
        entropy_check: bool = True,
        entropy_threshold: float = 4.5,
    ) -> None:
        self.patterns = patterns if patterns is not None else list(BUILTIN_PATTERNS)
        self.allowlist = allowlist if allowlist is not None else []
        self.entropy_check = entropy_check
        self.entropy_threshold = entropy_threshold
        self._compiled: list[tuple[CredentialPattern, re.Pattern[str]]] = []
        self._compile_patterns()

    def _compile_patterns(self) -> None:
        """Pre-compile all regex patterns."""
        self._compiled = []
        for pat in self.patterns:
            try:
                compiled = re.compile(pat.regex, re.IGNORECASE)
                self._compiled.append((pat, compiled))
            except re.error:
                pass  # Skip invalid patterns

    def add_pattern(self, pattern: CredentialPattern) -> None:
        """Add a custom pattern to the scanner."""
        self.patterns.append(pattern)
        try:
            compiled = re.compile(pattern.regex, re.IGNORECASE)
            self._compiled.append((pattern, compiled))
        except re.error:
            pass

    def add_allowlist_entry(self, entry: AllowlistEntry) -> None:
        """Add an allowlist entry for false positive suppression."""
        self.allowlist.append(entry)

    def scan_string(
        self, content: str, filename: str = "<string>"
    ) -> list[CredentialFinding]:
        """Scan string content for credentials.

        Args:
            content: The text content to scan.
            filename: Name to associate with findings.

        Returns:
            List of CredentialFinding objects.
        """
        findings: list[CredentialFinding] = []
        lines = content.split("\n")

        for line_no, line in enumerate(lines, start=1):
            # Skip comment-only lines (common false positives)
            stripped = line.strip()
            if stripped.startswith("#") and "=" not in stripped and ":" not in stripped:
                continue

            for pat, compiled in self._compiled:
                for match in compiled.finditer(line):
                    matched_text = match.group(0)
                    # Use first group if available (captures the secret part)
                    secret_part = match.group(1) if match.lastindex and match.lastindex >= 1 else matched_text

                    finding = CredentialFinding(
                        pattern_name=pat.name,
                        file=filename,
                        line=line_no,
                        masked_value=mask_value(secret_part),
                        severity=pat.severity,
                        description=pat.description,
                        column=match.start() + 1,
                        match_length=len(matched_text),
                    )

                    # Check allowlist
                    if any(al.matches(finding) for al in self.allowlist):
                        continue

                    findings.append(finding)

        # Entropy-based detection
        if self.entropy_check:
            for line_no, line in enumerate(lines, start=1):
                entropy_findings = self._check_entropy(line, line_no, filename)
                for ef in entropy_findings:
                    if not any(al.matches(ef) for al in self.allowlist):
                        findings.append(ef)

        return findings

    def _check_entropy(
        self, line: str, line_no: int, filename: str,
    ) -> list[CredentialFinding]:
        """Check a line for high-entropy strings that look like secrets."""
        findings: list[CredentialFinding] = []

        # Look for quoted strings or assignment values
        quoted_pattern = re.compile(r"""[=:]\s*['"]([A-Za-z0-9+/=_\-]{20,})['"]""")
        for match in quoted_pattern.finditer(line):
            value = match.group(1)
            if is_high_entropy(value, self.entropy_threshold):
                # Don't duplicate if already found by regex
                already_found = False
                for pat, compiled in self._compiled:
                    if compiled.search(line):
                        already_found = True
                        break
                if not already_found:
                    findings.append(CredentialFinding(
                        pattern_name="high_entropy",
                        file=filename,
                        line=line_no,
                        masked_value=mask_value(value),
                        severity=CredSeverity.MEDIUM,
                        description="High-entropy string (potential secret)",
                        column=match.start() + 1,
                        match_length=len(match.group(0)),
                    ))

        return findings

    def scan_file(self, path: Path) -> list[CredentialFinding]:
        """Scan a single file for credentials.

        Args:
            path: Path to the file to scan.

        Returns:
            List of CredentialFinding objects.
        """
        if path.suffix.lower() in _SKIP_EXTENSIONS:
            return []

        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except (OSError, PermissionError):
            return []

        return self.scan_string(content, filename=str(path))

    def scan_directory(
        self, path: Path, recursive: bool = True,
    ) -> ScanResult:
        """Scan a directory for credentials.

        Args:
            path: Directory path to scan.
            recursive: If True, scan subdirectories.

        Returns:
            ScanResult with all findings.
        """
        result = ScanResult()

        if not path.is_dir():
            result.errors.append(f"Not a directory: {path}")
            return result

        try:
            entries = sorted(path.iterdir())
        except PermissionError:
            result.errors.append(f"Permission denied: {path}")
            return result

        for entry in entries:
            if entry.is_dir():
                if entry.name in _SKIP_DIRS:
                    continue
                if recursive:
                    sub_result = self.scan_directory(entry, recursive=True)
                    result.findings.extend(sub_result.findings)
                    result.files_scanned += sub_result.files_scanned
                    result.lines_scanned += sub_result.lines_scanned
                    result.errors.extend(sub_result.errors)
            elif entry.is_file():
                if entry.suffix.lower() in _SKIP_EXTENSIONS:
                    continue
                try:
                    content = entry.read_text(encoding="utf-8", errors="ignore")
                    line_count = content.count("\n") + 1
                    result.lines_scanned += line_count
                    result.files_scanned += 1
                    findings = self.scan_string(content, filename=str(entry))
                    result.findings.extend(findings)
                except (OSError, PermissionError) as exc:
                    result.errors.append(f"Error reading {entry}: {exc}")

        return result


# ---------- Formatters ----------


def format_text(result: ScanResult) -> str:
    """Format scan result as human-readable text.

    Args:
        result: The ScanResult to format.

    Returns:
        Formatted text string.
    """
    lines: list[str] = []
    lines.append(f"\n{'=' * 60}")
    lines.append("  Credential Scanner Results")
    lines.append(f"{'=' * 60}")
    lines.append(f"  Files scanned: {result.files_scanned}")
    lines.append(f"  Lines scanned: {result.lines_scanned}")
    lines.append(f"  Findings:      {result.total_findings}")
    lines.append("")

    if result.findings:
        for f in result.findings:
            lines.append(f"  [{f.severity}] {f.pattern_name}")
            lines.append(f"    File: {f.file}:{f.line}")
            lines.append(f"    Value: {f.masked_value}")
            lines.append(f"    {f.description}")
            lines.append("")

    sc = result.severity_counts
    lines.append("  Severity counts:")
    for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
        if sc[sev] > 0:
            lines.append(f"    {sev}: {sc[sev]}")
    lines.append(f"\n{'=' * 60}")

    return "\n".join(lines)


def format_json(result: ScanResult) -> str:
    """Format scan result as JSON.

    Args:
        result: The ScanResult to format.

    Returns:
        JSON string.
    """
    return json.dumps(result.to_dict(), indent=2, ensure_ascii=False)


def format_sarif(result: ScanResult) -> str:
    """Format scan result as SARIF 2.1.0.

    Args:
        result: The ScanResult to format.

    Returns:
        SARIF JSON string.
    """
    rules_map: dict[str, dict[str, Any]] = {}
    sarif_results: list[dict[str, Any]] = []

    level_map = {
        "CRITICAL": "error",
        "HIGH": "error",
        "MEDIUM": "warning",
        "LOW": "note",
        "INFO": "note",
    }

    rank_map = {
        "CRITICAL": 9.5,
        "HIGH": 8.0,
        "MEDIUM": 5.0,
        "LOW": 3.0,
        "INFO": 1.0,
    }

    for finding in result.findings:
        rule_id = f"cred-{finding.pattern_name}"
        if rule_id not in rules_map:
            rules_map[rule_id] = {
                "id": rule_id,
                "name": finding.pattern_name,
                "shortDescription": {"text": finding.description},
                "defaultConfiguration": {
                    "level": level_map.get(finding.severity, "note"),
                },
                "properties": {
                    "security-severity": str(rank_map.get(finding.severity, 1.0)),
                    "tags": ["security", "credentials"],
                },
            }

        sarif_result: dict[str, Any] = {
            "ruleId": rule_id,
            "level": level_map.get(finding.severity, "note"),
            "message": {"text": f"{finding.description}: {finding.masked_value}"},
            "locations": [{
                "physicalLocation": {
                    "artifactLocation": {
                        "uri": finding.file,
                        "uriBaseId": "%SRCROOT%",
                    },
                    "region": {
                        "startLine": max(1, finding.line),
                        "startColumn": max(1, finding.column),
                    },
                },
            }],
        }
        sarif_results.append(sarif_result)

    sarif: dict[str, Any] = {
        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": {
                "driver": {
                    "name": "mcpaudit-credential-scanner",
                    "version": "2.3.0",
                    "rules": list(rules_map.values()),
                },
            },
            "results": sarif_results,
        }],
    }

    return json.dumps(sarif, indent=2, ensure_ascii=False)
