#!/usr/bin/env python3
"""
Donjon - Malware Scanner

Integrates YARA rule-based pattern matching and ClamAV antivirus scanning
for file-system malware detection.  Supports both tools independently --
uses whichever is available, or both for defense-in-depth.

Detection methods:
  - YARA:   Custom rule files + bundled rules for webshells, backdoors,
            crypto-miners, packers, suspicious scripts, and common malware
            families.  Uses the bundled yara64.exe or system yara.
  - ClamAV: Signature-based AV scanning via clamscan (offline) or
            clamdscan (daemon mode).  Requires freshclam DB updates.

Scan depths:
  - quick:    Scan common malware directories only, skip large files
  - standard: Full recursive scan of target paths
  - deep:     Full scan + YARA with all rule sets + heuristic checks
"""

import os
import sys
import json
import hashlib
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

try:
    from .base import BaseScanner
except ImportError:
    from base import BaseScanner

# Default YARA rules (inline) for when no external rules are provided.
# These detect common indicators of compromise across platforms.
_BUILTIN_YARA_RULES = r"""
rule Webshell_Generic {
    meta:
        description = "Detects generic webshell patterns"
        severity = "CRITICAL"
        category = "webshell"
    strings:
        $php1 = "eval($_POST" ascii nocase
        $php2 = "eval($_GET" ascii nocase
        $php3 = "eval($_REQUEST" ascii nocase
        $php4 = "base64_decode($_" ascii nocase
        $php5 = "system($_" ascii nocase
        $php6 = "passthru($_" ascii nocase
        $php7 = "shell_exec($_" ascii nocase
        $php8 = "<?php eval(" ascii nocase
        $asp1 = "Execute(Request" ascii nocase
        $asp2 = "eval(Request" ascii nocase
        $jsp1 = "Runtime.getRuntime().exec" ascii
    condition:
        any of them
}

rule Backdoor_Reverse_Shell {
    meta:
        description = "Detects reverse shell patterns"
        severity = "CRITICAL"
        category = "backdoor"
    strings:
        $bash1 = "/bin/bash -i >& /dev/tcp/" ascii
        $bash2 = "bash -c 'bash -i >& /dev/tcp/" ascii
        $nc1 = "nc -e /bin/" ascii
        $nc2 = "ncat -e /bin/" ascii
        $py1 = "socket.socket" ascii
        $py2 = "subprocess.call" ascii
        $py3 = "os.dup2(s.fileno()" ascii
        $ps1 = "New-Object System.Net.Sockets.TCPClient" ascii nocase
        $ps2 = "Invoke-PowerShellTcp" ascii nocase
    condition:
        ($py1 and $py2 and $py3) or $bash1 or $bash2 or $nc1 or $nc2 or
        $ps1 or $ps2
}

rule Cryptominer_Generic {
    meta:
        description = "Detects cryptocurrency mining indicators"
        severity = "HIGH"
        category = "cryptominer"
    strings:
        $s1 = "stratum+tcp://" ascii nocase
        $s2 = "stratum+ssl://" ascii nocase
        $s3 = "xmrig" ascii nocase
        $s4 = "coinhive" ascii nocase
        $s5 = "cryptonight" ascii nocase
        $s6 = "monero" ascii nocase
        $s7 = "pool.minexmr" ascii nocase
        $s8 = "hashrate" ascii nocase
        $s9 = "--donate-level" ascii nocase
    condition:
        2 of them
}

rule Suspicious_PowerShell {
    meta:
        description = "Detects obfuscated or malicious PowerShell"
        severity = "HIGH"
        category = "suspicious_script"
    strings:
        $enc1 = "-EncodedCommand" ascii nocase
        $enc2 = "-enc " ascii nocase
        $bypass1 = "-ExecutionPolicy Bypass" ascii nocase
        $bypass2 = "Set-ExecutionPolicy Unrestricted" ascii nocase
        $dl1 = "DownloadString(" ascii nocase
        $dl2 = "DownloadFile(" ascii nocase
        $dl3 = "Invoke-WebRequest" ascii nocase
        $dl4 = "Net.WebClient" ascii nocase
        $iex1 = "IEX(" ascii nocase
        $iex2 = "Invoke-Expression" ascii nocase
        $hidden = "-WindowStyle Hidden" ascii nocase
        $amsi = "AmsiUtils" ascii nocase
    condition:
        (($enc1 or $enc2) and ($dl1 or $dl2 or $dl3 or $dl4)) or
        ($iex1 or $iex2) and ($dl1 or $dl2 or $dl3 or $dl4) or
        ($bypass1 or $bypass2) and $hidden or
        $amsi
}

rule Suspicious_Packed_Executable {
    meta:
        description = "Detects UPX or common packer signatures"
        severity = "MEDIUM"
        category = "packer"
    strings:
        $upx = "UPX!" ascii
        $mpress = "MPRESS" ascii
        $aspack = "aPLib" ascii
        $petite = "Petite" ascii
        $mz = { 4D 5A }
    condition:
        $mz at 0 and ($upx or $mpress or $aspack or $petite)
}

rule Ransomware_Indicators {
    meta:
        description = "Detects common ransomware patterns"
        severity = "CRITICAL"
        category = "ransomware"
    strings:
        $ransom1 = "YOUR FILES HAVE BEEN ENCRYPTED" ascii nocase
        $ransom2 = "your files are encrypted" ascii nocase
        $ransom3 = "bitcoin" ascii nocase
        $ransom4 = "pay the ransom" ascii nocase
        $ransom5 = "decrypt your files" ascii nocase
        $ext1 = ".encrypted" ascii nocase
        $ext2 = ".locked" ascii nocase
        $ext3 = ".crypto" ascii nocase
    condition:
        2 of ($ransom*) or (1 of ($ransom*) and 1 of ($ext*))
}
"""

# Directories commonly targeted by malware
_QUICK_SCAN_DIRS_WINDOWS = [
    'C:\\Users\\Public',
    'C:\\Windows\\Temp',
    'C:\\ProgramData',
    'C:\\inetpub\\wwwroot',
]

# nosec B108 - these /tmp paths are intentional malware scan targets,
# not hardcoded temp file usage; the scanner checks them for malicious content.
_QUICK_SCAN_DIRS_LINUX = [
    '/tmp',       # nosec B108
    '/var/tmp',   # nosec B108
    '/dev/shm',   # nosec B108
    '/var/www',
    '/opt',
    '/usr/local/bin',
]

# File size limits
_MAX_FILE_SIZE_QUICK = 10 * 1024 * 1024       # 10 MB
_MAX_FILE_SIZE_STANDARD = 100 * 1024 * 1024    # 100 MB
_MAX_FILE_SIZE_DEEP = 500 * 1024 * 1024        # 500 MB

# Suspicious file extensions
_SUSPECT_EXTENSIONS = {
    '.exe', '.dll', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.vbe',
    '.js', '.jse', '.wsf', '.wsh', '.hta', '.cpl', '.msi', '.jar',
    '.py', '.rb', '.pl', '.sh', '.php', '.asp', '.aspx', '.jsp',
    '.war', '.elf', '.bin', '.com', '.pif', '.lnk',
}


class MalwareScanner(BaseScanner):
    """
    Malware detection scanner using YARA rules and ClamAV.

    Uses bundled yara64.exe/yara or system-installed yara for pattern
    matching, and clamscan/clamdscan for signature-based AV scanning.
    Both tools are optional -- the scanner adapts to what's available.
    """

    SCANNER_NAME = "malware"
    SCANNER_DESCRIPTION = "YARA rule matching + ClamAV antivirus scanning"

    def __init__(self, session_id: Optional[str] = None):
        super().__init__(session_id)
        self._yara_path: Optional[Path] = None
        self._clamscan_path: Optional[Path] = None
        self._custom_rules_dir: Optional[Path] = None
        self._detect_tools()

    def _detect_tools(self):
        """Locate YARA and ClamAV binaries."""
        # YARA - check bundled tools first, then system PATH
        tools_dir = self.paths.tools
        for name in ('yara64.exe', 'yara.exe', 'yara'):
            candidate = tools_dir / 'yara' / name
            if candidate.exists():
                self._yara_path = candidate
                self.scan_logger.info("YARA found (bundled): %s", candidate)
                break

        if not self._yara_path:
            # Check system PATH via subprocess (no shell=True)
            for name in ('yara64', 'yara'):
                try:
                    result = subprocess.run(
                        [name, '--version'],
                        capture_output=True, text=True, timeout=10,
                    )
                    if result.returncode == 0:
                        self._yara_path = Path(name)
                        self.scan_logger.info("YARA found (system): %s", name)
                        break
                except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                    continue

        # ClamAV - check system PATH (no shell, direct exec)
        for name in ('clamscan', 'clamscan.exe', 'clamdscan', 'clamdscan.exe'):
            try:
                result = subprocess.run(
                    [name, '--version'],
                    capture_output=True, text=True, timeout=10,
                )
                if result.returncode == 0:
                    self._clamscan_path = Path(name)
                    self.scan_logger.info("ClamAV found: %s", name)
                    break
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                continue

        # Custom YARA rules directory
        rules_dir = self.paths.data / 'yara_rules'
        if rules_dir.is_dir():
            self._custom_rules_dir = rules_dir
            self.scan_logger.info("Custom YARA rules: %s", rules_dir)

    def scan(self, targets: List[str] = None, scan_type: str = 'standard',
             **kwargs) -> Dict:
        """
        Execute malware scan against target paths.

        Args:
            targets: List of file/directory paths to scan. Defaults to current directory.
            scan_type: 'quick', 'standard', or 'deep'

        Returns:
            Scan summary dict
        """
        if isinstance(targets, str):
            targets = [targets]
        if targets is None:
            targets = [str(Path.cwd())]
        self.start_time = datetime.now(timezone.utc)
        self.scan_status = 'running'
        self.results = []
        self.findings = []

        # Check if all targets are IP addresses (not file paths)
        import re as _re
        ip_pattern = _re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d+)?$')
        if all(ip_pattern.match(t.strip()) for t in targets):
            self.scan_logger.info("Malware scanner requires file paths, not network targets")
            self.set_status('skipped', 'Malware scanner requires file paths, not network targets')
            self.end_time = datetime.now(timezone.utc)
            summary = self.get_summary()
            summary['scan_status'] = self.scan_status
            summary['warnings'] = self.warnings
            return summary

        if not self._yara_path and not self._clamscan_path:
            self.scan_logger.error(
                "No malware scanning tools available. "
                "Install YARA (bundled in tools/yara/) or ClamAV."
            )
            self.add_finding(
                severity='INFO',
                title='Malware scanning tools not available',
                description=(
                    'Neither YARA nor ClamAV were found. Install at least one '
                    'tool to enable malware detection. YARA is bundled in '
                    'tools/yara/ -- ensure yara64.exe is present.'
                ),
                affected_asset='scanner',
                finding_type='missing_tool',
                remediation='Install YARA or ClamAV for malware detection',
            )
            self.set_status('failed', 'No malware scanning tools available (YARA/ClamAV)')
            self.end_time = datetime.now(timezone.utc)
            summary = self.get_summary()
            summary['scan_status'] = self.scan_status
            summary['warnings'] = self.warnings
            return summary

        # Resolve target paths
        scan_paths = self._resolve_scan_paths(targets, scan_type)
        self.scan_logger.info(
            "Malware scan starting: %d path(s), depth=%s",
            len(scan_paths), scan_type,
        )

        # Determine file size limit
        if scan_type == 'quick':
            max_size = _MAX_FILE_SIZE_QUICK
        elif scan_type == 'deep':
            max_size = _MAX_FILE_SIZE_DEEP
        else:
            max_size = _MAX_FILE_SIZE_STANDARD

        files_scanned = 0
        files_matched = 0

        for path in scan_paths:
            path = Path(path)
            if not path.exists():
                self.scan_logger.warning("Path not found: %s", path)
                continue

            if path.is_file():
                files_to_scan = [path]
            else:
                files_to_scan = self._enumerate_files(path, max_size, scan_type)

            # YARA scan
            if self._yara_path and files_to_scan:
                yara_matches = self._run_yara_scan(files_to_scan, scan_type)
                files_matched += len(yara_matches)
                for match in yara_matches:
                    self._record_yara_finding(match)

            # ClamAV scan
            if self._clamscan_path:
                clam_matches = self._run_clamscan(path, scan_type)
                files_matched += len(clam_matches)
                for match in clam_matches:
                    self._record_clam_finding(match)

            files_scanned += len(files_to_scan)
            self.human_delay()

        self.add_result('malware_scan_complete', {
            'files_scanned': files_scanned,
            'detections': files_matched,
            'yara_available': self._yara_path is not None,
            'clamav_available': self._clamscan_path is not None,
            'scan_type': scan_type,
        })

        self.end_time = datetime.now(timezone.utc)
        self.set_status('complete')
        self.scan_logger.info(
            "Malware scan complete: %d files scanned, %d detections, %d findings",
            files_scanned, files_matched, len(self.findings),
        )

        if self.session_id:
            self.save_results()

        summary = self.get_summary()
        summary['scan_status'] = self.scan_status
        summary['warnings'] = self.warnings
        return summary

    # ------------------------------------------------------------------
    # Path resolution
    # ------------------------------------------------------------------

    def _resolve_scan_paths(self, targets: List[str],
                            scan_type: str) -> List[str]:
        """Resolve target specs into actual paths to scan."""
        if scan_type == 'quick' and not targets:
            # Default to common malware directories
            if sys.platform == 'win32':
                return [p for p in _QUICK_SCAN_DIRS_WINDOWS
                        if Path(p).exists()]
            else:
                return [p for p in _QUICK_SCAN_DIRS_LINUX
                        if Path(p).exists()]

        if targets:
            return targets

        # Standard/deep with no targets: scan current directory
        return [str(Path.cwd())]

    def _enumerate_files(self, directory: Path, max_size: int,
                         scan_type: str) -> List[Path]:
        """Enumerate files in a directory for scanning."""
        files = []
        try:
            for entry in directory.rglob('*'):
                if not entry.is_file():
                    continue
                try:
                    size = entry.stat().st_size
                    if size > max_size:
                        continue
                    if size == 0:
                        continue

                    # Quick mode: only scan suspicious extensions
                    if scan_type == 'quick':
                        if entry.suffix.lower() not in _SUSPECT_EXTENSIONS:
                            continue

                    files.append(entry)
                except (OSError, PermissionError):
                    continue
        except (OSError, PermissionError) as exc:
            self.scan_logger.warning("Cannot enumerate %s: %s", directory, exc)

        return files

    # ------------------------------------------------------------------
    # YARA scanning
    # ------------------------------------------------------------------

    def _run_yara_scan(self, files: List[Path],
                       scan_type: str) -> List[Dict]:
        """Run YARA against a list of files. Returns match dicts."""
        matches = []

        # Write builtin rules to a temp file
        rules_file = None
        try:
            with tempfile.NamedTemporaryFile(
                mode='w', suffix='.yar', delete=False, encoding='utf-8',
            ) as tf:
                tf.write(_BUILTIN_YARA_RULES)
                rules_file = tf.name

            # Collect all rule files
            rule_files = [rules_file]
            if self._custom_rules_dir and scan_type in ('standard', 'deep'):
                for rf in self._custom_rules_dir.glob('*.yar'):
                    rule_files.append(str(rf))
                for rf in self._custom_rules_dir.glob('*.yara'):
                    rule_files.append(str(rf))

            # Scan each file (using subprocess list args -- no shell injection)
            for fpath in files:
                for rf in rule_files:
                    try:
                        cmd = [str(self._yara_path), rf, str(fpath)]
                        result = subprocess.run(
                            cmd,
                            capture_output=True,
                            text=True,
                            timeout=30,
                        )
                        if result.stdout.strip():
                            for line in result.stdout.strip().split('\n'):
                                line = line.strip()
                                if not line:
                                    continue
                                # YARA output: "RuleName filepath"
                                parts = line.split(' ', 1)
                                if len(parts) >= 2:
                                    rule_name = parts[0]
                                    matched_file = parts[1]
                                else:
                                    rule_name = line
                                    matched_file = str(fpath)

                                # Get file hash for evidence
                                file_hash = self._hash_file(fpath)

                                matches.append({
                                    'tool': 'yara',
                                    'rule': rule_name,
                                    'file': matched_file,
                                    'file_hash': file_hash,
                                    'file_size': fpath.stat().st_size,
                                })
                    except subprocess.TimeoutExpired:
                        self.scan_logger.warning(
                            "YARA timeout scanning %s", fpath,
                        )
                    except (OSError, PermissionError) as exc:
                        self.scan_logger.debug("YARA error on %s: %s", fpath, exc)

        finally:
            if rules_file:
                try:
                    os.unlink(rules_file)
                except OSError:
                    pass

        return matches

    def _record_yara_finding(self, match: Dict):
        """Convert a YARA match into a scanner finding."""
        rule = match['rule']

        # Map YARA rule metadata to severity
        severity_map = {
            'Webshell_Generic': 'CRITICAL',
            'Backdoor_Reverse_Shell': 'CRITICAL',
            'Ransomware_Indicators': 'CRITICAL',
            'Cryptominer_Generic': 'HIGH',
            'Suspicious_PowerShell': 'HIGH',
            'Suspicious_Packed_Executable': 'MEDIUM',
        }
        severity = severity_map.get(rule, 'HIGH')

        category_map = {
            'Webshell_Generic': 'webshell',
            'Backdoor_Reverse_Shell': 'backdoor',
            'Ransomware_Indicators': 'ransomware',
            'Cryptominer_Generic': 'cryptominer',
            'Suspicious_PowerShell': 'suspicious_script',
            'Suspicious_Packed_Executable': 'packer',
        }
        category = category_map.get(rule, 'malware')

        self.add_finding(
            severity=severity,
            title="YARA match: %s" % rule,
            description=(
                "YARA rule '%s' matched file: %s\n"
                "Category: %s\n"
                "File SHA-256: %s\n"
                "File size: %s bytes"
                % (rule, match['file'], category,
                   match.get('file_hash', 'unknown'),
                   match.get('file_size', 0))
            ),
            affected_asset=match['file'],
            finding_type="malware_%s" % category,
            cvss_score=9.8 if severity == 'CRITICAL' else 7.5 if severity == 'HIGH' else 5.0,
            remediation=(
                "1. Quarantine the file immediately: %s\n"
                "2. Investigate how the file was introduced (access logs, upload history)\n"
                "3. Check for lateral movement from the affected system\n"
                "4. Scan all systems in the same network segment\n"
                "5. If webshell: audit web application for upload vulnerabilities\n"
                "6. Preserve a copy for forensic analysis before deletion"
                % match['file']
            ),
            raw_data=match,
            detection_method='yara_rule_match',
        )

    # ------------------------------------------------------------------
    # ClamAV scanning
    # ------------------------------------------------------------------

    def _run_clamscan(self, target_path: Path,
                      scan_type: str) -> List[Dict]:
        """Run ClamAV against a target path. Returns match dicts."""
        matches = []

        cmd = [str(self._clamscan_path)]

        # Build options based on scan depth
        if scan_type == 'quick':
            cmd.extend([
                '--max-filesize=10M',
                '--max-scansize=100M',
                '--max-recursion=3',
            ])
        elif scan_type == 'deep':
            cmd.extend([
                '--max-filesize=500M',
                '--max-scansize=2000M',
                '--max-recursion=20',
                '--detect-pua=yes',
                '--alert-encrypted=yes',
            ])
        else:
            cmd.extend([
                '--max-filesize=100M',
                '--max-scansize=500M',
                '--max-recursion=10',
            ])

        cmd.extend([
            '--recursive',
            '--no-summary',
            '--infected',
            str(target_path),
        ])

        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=600,  # 10 min timeout
            )

            # ClamAV exit code 1 = virus found, 0 = clean, 2 = error
            if result.returncode == 1 and result.stdout:
                for line in result.stdout.strip().split('\n'):
                    line = line.strip()
                    if not line or 'FOUND' not in line:
                        continue

                    # Format: "/path/to/file: MalwareName FOUND"
                    parts = line.rsplit(':', 1)
                    if len(parts) == 2:
                        file_path = parts[0].strip()
                        sig_info = parts[1].strip().replace(' FOUND', '')
                    else:
                        file_path = str(target_path)
                        sig_info = line

                    file_hash = self._hash_file(Path(file_path))

                    matches.append({
                        'tool': 'clamav',
                        'signature': sig_info,
                        'file': file_path,
                        'file_hash': file_hash,
                    })

        except subprocess.TimeoutExpired:
            self.scan_logger.warning("ClamAV timeout scanning %s", target_path)
        except (OSError, PermissionError) as exc:
            self.scan_logger.warning("ClamAV error: %s", exc)

        return matches

    def _record_clam_finding(self, match: Dict):
        """Convert a ClamAV match into a scanner finding."""
        signature = match['signature']

        # Determine severity from ClamAV signature naming conventions
        sig_lower = signature.lower()
        if any(kw in sig_lower for kw in ('trojan', 'backdoor', 'ransom', 'rootkit')):
            severity = 'CRITICAL'
            cvss = 9.8
        elif any(kw in sig_lower for kw in ('exploit', 'worm', 'keylogger')):
            severity = 'CRITICAL'
            cvss = 9.0
        elif any(kw in sig_lower for kw in ('miner', 'adware', 'spyware', 'dropper')):
            severity = 'HIGH'
            cvss = 7.5
        elif 'pua' in sig_lower or 'potentially' in sig_lower:
            severity = 'MEDIUM'
            cvss = 5.0
        else:
            severity = 'HIGH'
            cvss = 7.5

        self.add_finding(
            severity=severity,
            title="ClamAV detection: %s" % signature,
            description=(
                "ClamAV signature match: %s\n"
                "Infected file: %s\n"
                "File SHA-256: %s"
                % (signature, match['file'], match.get('file_hash', 'unknown'))
            ),
            affected_asset=match['file'],
            finding_type='malware_detection',
            cvss_score=cvss,
            remediation=(
                "1. Quarantine the infected file: %s\n"
                "2. Identify the malware family: %s\n"
                "3. Check for persistence mechanisms (scheduled tasks, services, cron)\n"
                "4. Scan the full system for additional infections\n"
                "5. Investigate the infection vector (email, download, exploit)\n"
                "6. Consider reimaging the system if the compromise is extensive"
                % (match['file'], signature)
            ),
            raw_data=match,
            detection_method='clamav_signature',
        )

    # ------------------------------------------------------------------
    # Utilities
    # ------------------------------------------------------------------

    @staticmethod
    def _hash_file(filepath: Path) -> str:
        """Calculate SHA-256 hash of a file."""
        try:
            h = hashlib.sha256()
            with open(filepath, 'rb') as f:
                while True:
                    chunk = f.read(8192)
                    if not chunk:
                        break
                    h.update(chunk)
            return h.hexdigest()
        except (OSError, PermissionError):
            return 'unavailable'
