import hashlib
import base64
import copy
import json
import math
import os
import re
import socket
import subprocess
import time
import shutil
import uuid
import urllib.error
import urllib.request
import ssl
import sys
from typing import Callable, Dict, List, Optional, Tuple

if sys.platform == "win32":
    import ctypes
    from ctypes import wintypes

if sys.platform == "win32":
    import ctypes
    from ctypes import wintypes

from cryptography.exceptions import InvalidSignature
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

from hawkSoar.common.logger import get_logger


class HybridTaskExecutor(object):
    REQUIRED_FIELDS = (
        "task_id",
        "group_id",
        "case_id",
        "category",
        "action",
        "parameters",
        "requires_approval",
        "timeout",
        "priority",
    )

    ALLOWED_CATEGORIES = {"AD", "ENDPOINT", "NETWORK", "CONTAINMENT", "DEPLOY"}
    ALLOWED_PRIORITIES = {"LOW", "MEDIUM", "HIGH", "CRITICAL"}

    ALLOWED_ACTIONS = {
        "AD": {
            "get_user_details",
            "enumerate_group_membership",
            "reset_password",
            "disable_account",
            "enable_account",
            "retrieve_last_logon",
            "audit_privileged_group_membership",
            "ad_replication_drift_probe",
            "gpo_startup_script_view",
        },
        "ENDPOINT": {
            "wmic_query",
            "running_processes",
            "list_processes",
            "list_users",
            "logged_in_users",
            "logged_in_users_login_time",
            "installed_software",
            "services_status",
            "scheduled_tasks",
            "event_log_extraction",
            "event_log_enriched",
            "list_event_log_channels",
            "network_connections",
            "file_exists_hash",
            "registry_query",
            "registry_read",
            "disk_read",
            "list_drives",
            "terminate_task",
            "process_snapshot_to_disk",
            "register_windows_service",
            "unregister_windows_service",
            "windows_service_status",
            "restart_windows_service",
            "run_command",
            "register_dynamic_tool",
            "remote_wmic_query",
            "remote_command_execute",
            "remote_registry_query",
            "remote_config_update",
            "remote_service_restart",
            "remote_process_restart",
            "memory_suspect_modules",
            "living_off_land_detector",
            "remote_process_snapshot",
            "remote_process_snapshot_by_name",
            "remote_file_system_list",
            "remote_file_collect",
            "startup_programs",
            "open_ports",
            "active_connections",
            "dns_query_history",
            "run_powershell",
            "powershell_script_block_history",
            "prefetch_analysis",
            "process_modules",
            "lateral_movement_artifacts",
            "lsass_access_indicators",
            "shadow_copy_status",
            "named_pipe_enumeration",
            "ads_scanner",
            "amcache_shimcache",
            "browser_history",
            "process_hook_scan",
            "wmi_persistence_deep",
            "etw_tamper_detection",
            "kernel_driver_scan",
            "token_privilege_inspect",
            "memory_ioc_extractor",
            "com_hijack_detector",
            "file_anomaly_detector",
            "usb_media_history",
            "logon_session_enum",
            "share_open_file_enum",
            "credential_theft_indicators",
            "defender_exclusion_audit",
            "kerberos_ticket_inspect",
            "scheduled_task_deep",
            "dll_hijack_detector",
            "certificate_store_audit",
            "ntfs_journal_analysis",
            "ad_recon_indicators",
            "ransomware_indicators",
            "clipboard_history",
            "lsa_provider_audit",
            "wdigest_credential_check",
            "mft_analyzer",
            "autoruns_deep",
            "office_macro_scan",
            "network_proxy_config",
            "powershell_profile_audit",
            "pipe_and_rpc_server_enum",
            "process_injection_scan",
        },
        "NETWORK": {
            "ping",
            "traceroute",
            "dns_lookup",
            "arp_table",
            "route_table",
            "smb_sessions",
            "smb_share_query",
            "rpc_query",
            "port_scan",
            "http_client",
            "smb_client",
            "dns_tunnel_score",
            "legacy_protocol_exposure_map",
            "honeypot_early_warning",
            "deception_signal_fuser",
        },
        "CONTAINMENT": {
            "disable_user",
            "kill_process",
            "stop_service",
            "add_firewall_block_rule",
            "host_isolation",
            "logoff_user",
        },
        "DEPLOY": {
            "deploy_hawk_soard",
            "verify_hawk_soard",
        },
    }

    ALLOWED_WMIC_QUERIES = {
        "process_list_brief": ["wmic", "process", "list", "brief"],
        "computersystem_get_username": ["wmic", "computersystem", "get", "username"],
        "service_list": ["wmic", "service", "list"],
        "qfe_list": ["wmic", "qfe", "list"],
    }

    _SAFE_TOKEN = re.compile(r"^[A-Za-z0-9._\\:-]{1,255}$")

    def __init__(
        self,
        config: Optional[Dict] = None,
        expected_group_id_getter: Optional[Callable[[], Optional[str]]] = None,
        command_runner: Optional[Callable[[list, int], Tuple[int, str, str]]] = None,
    ):
        self.config = config or {}
        self.logger = get_logger("security.hybrid_execution")
        self.expected_group_id_getter = expected_group_id_getter
        self.command_runner = command_runner or self._run_command

        self.enforce_signed_tasks = self._to_bool(
            self.config.get("enforce_signed_hybrid_tasks", True)
        )
        self.enforce_group_id = self._to_bool(self.config.get("enforce_group_id", True))
        self.max_timeout = int(self.config.get("max_task_timeout", 300))
        self.enable_remote_wmi_collection = self._to_bool(
            self.config.get("enable_remote_wmi_collection", False)
        )
        self.remote_wmi_allowed_targets = self._parse_csv_list(
            self.config.get("remote_wmi_allowed_targets", "")
        )
        self.enable_remote_process_snapshot = self._to_bool(
            self.config.get("enable_remote_process_snapshot", False)
        )
        self.remote_snapshot_allowed_targets = self._parse_csv_list(
            self.config.get("remote_snapshot_allowed_targets", "")
        )
        self.max_inline_artifact_bytes = int(
            self.config.get("max_inline_artifact_bytes", 524288)
        )
        self.enable_remote_file_collection = self._to_bool(
            self.config.get("enable_remote_file_collection", False)
        )
        self.remote_file_allowed_targets = self._parse_csv_list(
            self.config.get("remote_file_allowed_targets", "")
        )
        self.enable_remote_command_execution = self._to_bool(
            self.config.get("enable_remote_command_execution", False)
        )
        self.remote_command_allowed_targets = self._parse_csv_list(
            self.config.get("remote_command_allowed_targets", "")
        )
        self.enable_remote_registry_read = self._to_bool(
            self.config.get("enable_remote_registry_read", False)
        )
        self.remote_registry_allowed_targets = self._parse_csv_list(
            self.config.get("remote_registry_allowed_targets", "")
        )
        self.enable_remote_config_management = self._to_bool(
            self.config.get("enable_remote_config_management", False)
        )
        self.remote_config_allowed_targets = self._parse_csv_list(
            self.config.get("remote_config_allowed_targets", "")
        )
        self.enable_remote_deploy = self._to_bool(
            self.config.get("enable_remote_deploy", False)
        )
        self.remote_deploy_allowed_targets = self._parse_csv_list(
            self.config.get("remote_deploy_allowed_targets", "")
        )
        self.enable_command_execution = self._to_bool(
            self.config.get("enable_command_execution", True)
        )
        self.max_disk_read_bytes = int(
            self.config.get("max_disk_read_bytes", 104857600)
        )
        self.dynamic_tools_file = self._get_dynamic_tools_file()
        self.public_key = self._load_public_key(
            self.config.get("task_signing_public_key", None)
        )

        self._action_handlers = {
            "get_user_details": self._ad_get_user_details,
            "enumerate_group_membership": self._ad_enumerate_group_membership,
            "reset_password": self._ad_reset_password,
            "disable_account": self._ad_disable_account,
            "enable_account": self._ad_enable_account,
            "retrieve_last_logon": self._ad_retrieve_last_logon,
            "audit_privileged_group_membership": self._ad_audit_privileged_group_membership,
            "ad_replication_drift_probe": self._ad_replication_drift_probe,
            "gpo_startup_script_view": self._ad_gpo_startup_script_view,
            "wmic_query": self._endpoint_wmic_query,
            "running_processes": self._endpoint_running_processes,
            "list_processes": self._endpoint_list_processes,
            "list_users": self._endpoint_list_users,
            "logged_in_users": self._endpoint_logged_in_users,
            "logged_in_users_login_time": self._endpoint_logged_in_users_login_time,
            "installed_software": self._endpoint_installed_software,
            "services_status": self._endpoint_services_status,
            "scheduled_tasks": self._endpoint_scheduled_tasks,
            "event_log_extraction": self._endpoint_event_log_extraction,
            "event_log_enriched": self._endpoint_event_log_enriched,
            "list_event_log_channels": self._endpoint_list_event_log_channels,
            "network_connections": self._endpoint_network_connections,
            "file_exists_hash": self._endpoint_file_exists_hash,
            "registry_query": self._endpoint_registry_query,
            "registry_read": self._endpoint_registry_read,
            "disk_read": self._endpoint_disk_read,
            "list_drives": self._endpoint_list_drives,
            "terminate_task": self._endpoint_terminate_task,
            "process_snapshot_to_disk": self._endpoint_process_snapshot_to_disk,
            "register_windows_service": self._endpoint_register_windows_service,
            "unregister_windows_service": self._endpoint_unregister_windows_service,
            "windows_service_status": self._endpoint_windows_service_status,
            "restart_windows_service": self._endpoint_restart_windows_service,
            "run_command": self._endpoint_run_command,
            "register_dynamic_tool": self._endpoint_register_dynamic_tool,
            "remote_wmic_query": self._endpoint_remote_wmic_query,
            "remote_command_execute": self._endpoint_remote_command_execute,
            "remote_registry_query": self._endpoint_remote_registry_query,
            "remote_config_update": self._endpoint_remote_config_update,
            "remote_service_restart": self._endpoint_remote_service_restart,
            "remote_process_restart": self._endpoint_remote_process_restart,
            "memory_suspect_modules": self._endpoint_memory_suspect_modules,
            "living_off_land_detector": self._endpoint_living_off_land_detector,
            "remote_process_snapshot": self._endpoint_remote_process_snapshot,
            "remote_process_snapshot_by_name": self._endpoint_remote_process_snapshot_by_name,
            "remote_file_system_list": self._endpoint_remote_file_system_list,
            "remote_file_collect": self._endpoint_remote_file_collect,
            "startup_programs": self._endpoint_startup_programs,
            "open_ports": self._endpoint_open_ports,
            "active_connections": self._endpoint_active_connections,
            "dns_query_history": self._endpoint_dns_query_history,
            "run_powershell": self._endpoint_run_powershell,
            "powershell_script_block_history": self._endpoint_powershell_script_block_history,
            "prefetch_analysis": self._endpoint_prefetch_analysis,
            "process_modules": self._endpoint_process_modules,
            "lateral_movement_artifacts": self._endpoint_lateral_movement_artifacts,
            "lsass_access_indicators": self._endpoint_lsass_access_indicators,
            "shadow_copy_status": self._endpoint_shadow_copy_status,
            "named_pipe_enumeration": self._endpoint_named_pipe_enumeration,
            "ads_scanner": self._endpoint_ads_scanner,
            "amcache_shimcache": self._endpoint_amcache_shimcache,
            "browser_history": self._endpoint_browser_history,
            "process_hook_scan": self._endpoint_process_hook_scan,
            "wmi_persistence_deep": self._endpoint_wmi_persistence_deep,
            "etw_tamper_detection": self._endpoint_etw_tamper_detection,
            "kernel_driver_scan": self._endpoint_kernel_driver_scan,
            "token_privilege_inspect": self._endpoint_token_privilege_inspect,
            "memory_ioc_extractor": self._endpoint_memory_ioc_extractor,
            "com_hijack_detector": self._endpoint_com_hijack_detector,
            "file_anomaly_detector": self._endpoint_file_anomaly_detector,
            "usb_media_history": self._endpoint_usb_media_history,
            "logon_session_enum": self._endpoint_logon_session_enum,
            "share_open_file_enum": self._endpoint_share_open_file_enum,
            "credential_theft_indicators": self._endpoint_credential_theft_indicators,
            "defender_exclusion_audit": self._endpoint_defender_exclusion_audit,
            "kerberos_ticket_inspect": self._endpoint_kerberos_ticket_inspect,
            "scheduled_task_deep": self._endpoint_scheduled_task_deep,
            "dll_hijack_detector": self._endpoint_dll_hijack_detector,
            "certificate_store_audit": self._endpoint_certificate_store_audit,
            "ntfs_journal_analysis": self._endpoint_ntfs_journal_analysis,
            "ad_recon_indicators": self._endpoint_ad_recon_indicators,
            "ransomware_indicators": self._endpoint_ransomware_indicators,
            "clipboard_history": self._endpoint_clipboard_history,
            "lsa_provider_audit": self._endpoint_lsa_provider_audit,
            "wdigest_credential_check": self._endpoint_wdigest_credential_check,
            "mft_analyzer": self._endpoint_mft_analyzer,
            "autoruns_deep": self._endpoint_autoruns_deep,
            "office_macro_scan": self._endpoint_office_macro_scan,
            "network_proxy_config": self._endpoint_network_proxy_config,
            "powershell_profile_audit": self._endpoint_powershell_profile_audit,
            "pipe_and_rpc_server_enum": self._endpoint_pipe_and_rpc_server_enum,
            "process_injection_scan": self._endpoint_process_injection_scan,
            "ping": self._network_ping,
            "traceroute": self._network_traceroute,
            "dns_lookup": self._network_dns_lookup,
            "arp_table": self._network_arp_table,
            "route_table": self._network_route_table,
            "smb_sessions": self._network_smb_sessions,
            "smb_share_query": self._network_smb_share_query,
            "rpc_query": self._network_rpc_query,
            "port_scan": self._network_port_scan,
            "http_client": self._network_http_client,
            "smb_client": self._network_smb_client,
            "dns_tunnel_score": self._network_dns_tunnel_score,
            "legacy_protocol_exposure_map": self._network_legacy_protocol_exposure_map,
            "honeypot_early_warning": self._network_honeypot_early_warning,
            "deception_signal_fuser": self._network_deception_signal_fuser,
            "disable_user": self._containment_disable_user,
            "kill_process": self._containment_kill_process,
            "stop_service": self._containment_stop_service,
            "add_firewall_block_rule": self._containment_add_firewall_block_rule,
            "host_isolation": self._containment_host_isolation,
            "logoff_user": self._containment_logoff_user,
            "deploy_hawk_soard": self._deploy_hawk_soard,
            "verify_hawk_soard": self._verify_hawk_soard,
        }
        self.dynamic_tools = []
        self.dynamic_tool_actions = {}
        self._refresh_dynamic_tools()

    def process(self, task: Dict, signature: Optional[str] = None, host: Optional[str] = None):
        start = time.time()
        host_name = host or socket.gethostname()
        task_obj = task if isinstance(task, dict) else None
        task_id = task_obj.get("task_id", "") if task_obj else ""
        group_id = task_obj.get("group_id", "") if task_obj else ""
        status = "FAILED"
        output = {}
        error = None
        raw_text = ""

        try:
            self._validate_task_schema(task)
            self._validate_group_id(task)
            self._validate_signature(task, signature)
            self._validate_action_policy(task)

            action = task["action"]
            timeout = self._bounded_timeout(task["timeout"])
            handler = self._action_handlers.get(action)
            if handler:
                output, raw_text = handler(task["parameters"], timeout)
            else:
                output, raw_text = self._execute_dynamic_tool(action, task["parameters"], timeout)
            status = "SUCCESS"
        except PermissionError as exc:
            status = "BLOCKED"
            error = str(exc)
            raw_text = str(exc)
        except Exception as exc:
            status = "FAILED"
            error = str(exc)
            raw_text = str(exc)

        duration_ms = int((time.time() - start) * 1000)
        raw_output_hash = self._sha256(raw_text)
        response = {
            "task_id": task_id,
            "group_id": group_id,
            "status": status,
            "host": host_name,
            "execution_time_ms": duration_ms,
            "output": output,
            "raw_output_hash": raw_output_hash,
            "error": error,
        }
        self._log_audit_event(task, response, duration_ms)
        return response

    def _validate_task_schema(self, task: Dict):
        if not isinstance(task, dict):
            raise PermissionError("Task payload must be a JSON object.")

        for field in self.REQUIRED_FIELDS:
            if field not in task:
                raise PermissionError("Task missing required field: %s" % field)

        if not isinstance(task["parameters"], dict):
            raise PermissionError("Task parameters must be a structured object.")

        if task["category"] not in self.ALLOWED_CATEGORIES:
            raise PermissionError("Invalid task category.")
        if task["priority"] not in self.ALLOWED_PRIORITIES:
            raise PermissionError("Invalid task priority.")

        if not isinstance(task["requires_approval"], bool):
            raise PermissionError("requires_approval must be a boolean.")

        try:
            timeout = int(task["timeout"])
        except Exception:
            raise PermissionError("timeout must be an integer.")
        if timeout < 1 or timeout > self.max_timeout:
            raise PermissionError("timeout must be between 1 and %d seconds." % self.max_timeout)

        banned_fields = {"raw_command", "powershell", "encoded_command", "shell"}
        if task.get("action") != "register_dynamic_tool":
            banned_fields.add("command")
        for banned in banned_fields:
            if banned in task["parameters"]:
                raise PermissionError("Freeform command fields are forbidden.")

    def _validate_group_id(self, task: Dict):
        if not self.enforce_group_id:
            return
        if not self.expected_group_id_getter:
            raise PermissionError("Group enforcement is enabled but local group resolver is missing.")
        expected = self.expected_group_id_getter()
        if not expected:
            raise PermissionError("Local node group is unavailable; refusing task.")
        if task.get("group_id") != expected:
            raise PermissionError("Task group_id does not match registered node group.")

    def _validate_signature(self, task: Dict, signature: Optional[str]):
        if not self.enforce_signed_tasks:
            return
        if not self.public_key:
            raise PermissionError("Task signature verification key not configured.")
        if not signature:
            raise PermissionError("Signed tasks require a signature.")

        payload = self._canonical_task_bytes(task)
        try:
            sig_bytes = bytes.fromhex(signature)
        except Exception:
            raise PermissionError("Signature must be a hex-encoded string.")

        try:
            self.public_key.verify(
                sig_bytes, payload, padding.PKCS1v15(), hashes.SHA256()
            )
        except InvalidSignature:
            raise PermissionError("Task signature verification failed.")

    def _validate_action_policy(self, task: Dict):
        category = task["category"]
        action = task["action"]
        allowed = set(self.ALLOWED_ACTIONS[category])
        allowed.update(self.dynamic_allowed_actions.get(category, set()))
        if action not in allowed:
            raise PermissionError("Action is not allowlisted for category.")

        if category == "CONTAINMENT" and not task["requires_approval"]:
            raise PermissionError("Containment actions require approval.")
        if action == "remote_process_snapshot" and not task["requires_approval"]:
            raise PermissionError("remote_process_snapshot requires approval.")
        if action == "remote_process_snapshot_by_name" and not task["requires_approval"]:
            raise PermissionError("remote_process_snapshot_by_name requires approval.")
        if action == "run_command" and not task["requires_approval"]:
            raise PermissionError("run_command requires approval.")
        if action == "register_dynamic_tool" and not task["requires_approval"]:
            raise PermissionError("register_dynamic_tool requires approval.")
        if action == "terminate_task" and not task["requires_approval"]:
            raise PermissionError("terminate_task requires approval.")
        if action == "process_snapshot_to_disk" and not task["requires_approval"]:
            raise PermissionError("process_snapshot_to_disk requires approval.")
        if action == "register_windows_service" and not task["requires_approval"]:
            raise PermissionError("register_windows_service requires approval.")
        if action == "unregister_windows_service" and not task["requires_approval"]:
            raise PermissionError("unregister_windows_service requires approval.")
        if action == "restart_windows_service" and not task["requires_approval"]:
            raise PermissionError("restart_windows_service requires approval.")
        if action == "remote_command_execute" and not task["requires_approval"]:
            raise PermissionError("remote_command_execute requires approval.")
        if action == "remote_config_update" and not task["requires_approval"]:
            raise PermissionError("remote_config_update requires approval.")
        if action == "remote_service_restart" and not task["requires_approval"]:
            raise PermissionError("remote_service_restart requires approval.")
        if action == "remote_process_restart" and not task["requires_approval"]:
            raise PermissionError("remote_process_restart requires approval.")

    def _bounded_timeout(self, task_timeout):
        timeout = int(task_timeout)
        if timeout < 1:
            return 1
        if timeout > self.max_timeout:
            return self.max_timeout
        return timeout

    def _run_command(self, cmd, timeout):
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            shell=False,
            timeout=timeout,
            check=False,
        )
        return proc.returncode, proc.stdout, proc.stderr

    def _exec_command_structured(self, cmd, timeout):
        code, out, err = self.command_runner(cmd, timeout)
        out = out or ""
        err = err or ""
        raw = out + ("\n" + err if err else "")
        return (
            {
                "command": cmd,
                "stdout": out,
                "stderr": err,
                "exit_code": code,
            },
            raw,
        )

    def _list_processes_windows_api(self) -> List[Dict]:
        """Enumerate processes using Windows API CreateToolhelp32Snapshot.

        Returns rich process information including:
        - pid: Process ID
        - ppid: Parent Process ID
        - name: Process name (executable)
        - threads: Number of threads
        - base_priority: Base priority class
        - exe_path: Full executable path (if accessible)
        """
        if sys.platform != "win32":
            return []

        processes = []

        # Windows API constants
        TH32CS_SNAPPROCESS = 0x00000002
        PROCESS_QUERY_LIMITED_INFORMATION = 0x1000

        # PROCESSENTRY32 structure
        class PROCESSENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize", wintypes.DWORD),
                ("cntUsage", wintypes.DWORD),
                ("th32ProcessID", wintypes.DWORD),
                ("th32DefaultHeapID", ctypes.POINTER(wintypes.ULONG)),
                ("th32ModuleID", wintypes.DWORD),
                ("cntThreads", wintypes.DWORD),
                ("th32ParentProcessID", wintypes.DWORD),
                ("pcPriClassBase", wintypes.LONG),
                ("dwFlags", wintypes.DWORD),
                ("szExeFile", wintypes.CHAR * 260),
            ]

        kernel32 = ctypes.windll.kernel32

        # Create snapshot of all processes
        h_snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
        if h_snapshot == -1:
            return processes

        try:
            pe32 = PROCESSENTRY32()
            pe32.dwSize = ctypes.sizeof(PROCESSENTRY32)

            # Get first process
            if not kernel32.Process32First(h_snapshot, ctypes.byref(pe32)):
                return processes

            while True:
                pid = pe32.th32ProcessID
                ppid = pe32.th32ParentProcessID
                name = pe32.szExeFile.decode("utf-8", errors="replace")

                # Try to get executable path (requires PROCESS_QUERY_LIMITED_INFORMATION)
                exe_path = None
                try:
                    h_process = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
                    if h_process:
                        try:
                            # QueryFullProcessImageNameA
                            size = wintypes.DWORD(260)
                            buf = ctypes.create_string_buffer(260)
                            if kernel32.QueryFullProcessImageNameA(h_process, 0, buf, ctypes.byref(size)):
                                exe_path = buf.value.decode("utf-8", errors="replace")
                        finally:
                            kernel32.CloseHandle(h_process)
                except Exception:
                    pass

                proc_info = {
                    "pid": pid,
                    "ppid": ppid,
                    "name": name,
                    "threads": pe32.cntThreads,
                    "base_priority": pe32.pcPriClassBase,
                }
                if exe_path:
                    proc_info["exe_path"] = exe_path

                processes.append(proc_info)

                # Get next process
                if not kernel32.Process32Next(h_snapshot, ctypes.byref(pe32)):
                    break
        finally:
            kernel32.CloseHandle(h_snapshot)

        return processes

    def _list_processes_linux_proc(self) -> List[Dict]:
        """Enumerate processes by reading /proc filesystem on Linux.

        Returns rich process information including:
        - pid: Process ID
        - ppid: Parent Process ID
        - name: Process name (comm)
        - cmdline: Full command line
        - exe_path: Symlink to executable (if accessible)
        """
        if not os.path.exists("/proc"):
            return []

        processes = []
        for entry in os.listdir("/proc"):
            if not entry.isdigit():
                continue
            try:
                pid = int(entry)
                proc_info = {"pid": pid}

                # Read /proc/[pid]/stat for basic info
                stat_path = f"/proc/{pid}/stat"
                try:
                    with open(stat_path, "r") as f:
                        stat_data = f.read()
                    # Format: pid (comm) state ppid ...
                    # Find the last ) to handle comm with parens
                    paren_end = stat_data.rfind(")")
                    if paren_end != -1:
                        paren_start = stat_data.find("(")
                        if paren_start != -1:
                            proc_info["name"] = stat_data[paren_start + 1:paren_end]
                        fields = stat_data[paren_end + 2:].split()
                        if len(fields) >= 1:
                            proc_info["ppid"] = int(fields[1])
                except (IOError, ValueError, IndexError):
                    continue

                # Read command line
                cmdline_path = f"/proc/{pid}/cmdline"
                try:
                    with open(cmdline_path, "r") as f:
                        cmdline = f.read().replace("\x00", " ").strip()
                        if cmdline:
                            proc_info["cmdline"] = cmdline
                except IOError:
                    pass

                # Read executable symlink
                exe_path = f"/proc/{pid}/exe"
                try:
                    exe_target = os.readlink(exe_path)
                    proc_info["exe_path"] = exe_target
                except (IOError, OSError):
                    pass

                if "name" in proc_info:
                    processes.append(proc_info)
            except (IOError, ValueError):
                continue

        return processes

    def _list_processes_macos_proc(self) -> List[Dict]:
        """Enumerate processes on macOS using ctypes to call proc_listallpids.

        Returns process information including:
        - pid: Process ID
        - name: Process name
        """
        if sys.platform != "darwin":
            return []

        processes = []
        try:
            libc = ctypes.CDLL("libSystem.dylib")

            # proc_listallpids returns the number of pids
            # int proc_listallpids(void *buffer, int buffersize)
            proc_listallpids = libc.proc_listallpids
            proc_listallpids.argtypes = [ctypes.c_void_p, ctypes.c_int]
            proc_listallpids.restype = ctypes.c_int

            # First call to get buffer size
            bufsize = proc_listallpids(None, 0)
            if bufsize <= 0:
                return processes

            # Allocate buffer for pids (each pid is 4 bytes)
            pid_bufsize = bufsize * 4 + 1024  # extra space
            pid_buffer = (ctypes.c_int * (pid_bufsize // 4))()

            num_pids = proc_listallpids(ctypes.byref(pid_buffer), pid_bufsize)
            if num_pids <= 0:
                return processes

            # Get process info using proc_pidinfo
            # struct proc_bsdinfo has the info we need
            PROC_PIDTBSDINFO = 3
            proc_pidinfo = libc.proc_pidinfo
            proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_uint, ctypes.c_uint,
                                      ctypes.c_void_p, ctypes.c_int]
            proc_pidinfo.restype = ctypes.c_int

            class proc_bsdinfo(ctypes.Structure):
                _fields_ = [
                    ("pbi_flags", ctypes.c_uint32),
                    ("pbi_status", ctypes.c_uint32),
                    ("pbi_xstatus", ctypes.c_uint32),
                    ("pbi_pid", ctypes.c_uint32),
                    ("pbi_ppid", ctypes.c_uint32),
                    ("pbi_uid", ctypes.c_uid_t),
                    ("pbi_gid", ctypes.c_gid_t),
                    ("pbi_ruid", ctypes.c_uid_t),
                    ("pbi_rgid", ctypes.c_gid_t),
                    ("pbi_svuid", ctypes.c_uid_t),
                    ("pbi_svgid", ctypes.c_gid_t),
                    ("pbi_rfu", ctypes.c_uint32 * 3),
                    ("pbi_comm", ctypes.c_char * 16),
                    ("pbi_name", ctypes.c_char * 32),
                ]

            for i in range(min(num_pids, len(pid_buffer))):
                pid = pid_buffer[i]
                if pid <= 0:
                    continue
                try:
                    info = proc_bsdinfo()
                    ret = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0,
                                        ctypes.byref(info), ctypes.sizeof(info))
                    if ret > 0:
                        proc_info = {
                            "pid": info.pbi_pid,
                            "ppid": info.pbi_ppid,
                            "name": info.pbi_comm.decode("utf-8", errors="replace") or
                                    info.pbi_name.decode("utf-8", errors="replace"),
                        }
                        processes.append(proc_info)
                except Exception:
                    continue
        except Exception:
            pass

        return processes

    def _list_processes_linux_proc(self) -> List[Dict]:
        """Enumerate processes by reading /proc filesystem on Linux.

        Returns rich process information including:
        - pid: Process ID
        - ppid: Parent Process ID
        - name: Process name (comm)
        - cmdline: Full command line
        - exe: Symlink to executable (if accessible)
        """
        if sys.platform not in ("linux", "linux2"):
            return []

        processes = []
        proc_path = "/proc"

        try:
            for entry in os.listdir(proc_path):
                if not entry.isdigit():
                    continue

                pid = int(entry)
                proc_dir = os.path.join(proc_path, entry)
                proc_info = {"pid": pid}

                try:
                    # Read /proc/[pid]/stat for ppid and other info
                    stat_path = os.path.join(proc_dir, "stat")
                    if os.path.exists(stat_path):
                        with open(stat_path, "r") as f:
                            stat_data = f.read()
                        # Format: pid (comm) state ppid ...
                        # Find comm by matching parentheses
                        paren_start = stat_data.find("(")
                        paren_end = stat_data.rfind(")")
                        if paren_start != -1 and paren_end != -1:
                            proc_info["name"] = stat_data[paren_start + 1 : paren_end]
                            # Parse fields after comm
                            fields = stat_data[paren_end + 2 :].split()
                            if len(fields) >= 2:
                                proc_info["ppid"] = int(fields[1])

                    # Read /proc/[pid]/cmdline for full command line
                    cmdline_path = os.path.join(proc_dir, "cmdline")
                    if os.path.exists(cmdline_path):
                        with open(cmdline_path, "r") as f:
                            cmdline_data = f.read()
                        if cmdline_data:
                            # cmdline uses null bytes as separators
                            cmdline = cmdline_data.replace("\x00", " ").strip()
                            if cmdline:
                                proc_info["cmdline"] = cmdline

                    # Read /proc/[pid]/exe symlink for executable path
                    exe_path = os.path.join(proc_dir, "exe")
                    if os.path.islink(exe_path):
                        try:
                            exe_target = os.readlink(exe_path)
                            if not exe_target.startswith("["):
                                proc_info["exe_path"] = exe_target
                        except (PermissionError, OSError):
                            pass

                    if "ppid" in proc_info:
                        processes.append(proc_info)
                except (PermissionError, OSError, ProcessLookupError):
                    continue
        except OSError:
            pass

        return processes

    def _list_processes_macos_libc(self) -> List[Dict]:
        """Enumerate processes using libc proc_listallpids on macOS.

        Returns rich process information including:
        - pid: Process ID
        - name: Process name
        - path: Full executable path (if accessible)
        """
        if sys.platform != "darwin":
            return []

        processes = []

        try:
            import ctypes.util

            libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)

            # proc_listallpids returns the number of pids or -1 on error
            # First call with NULL to get the buffer size needed
            num_pids = libc.proc_listallpids(None, 0)
            if num_pids <= 0:
                return processes

            # Allocate buffer and get actual pids
            pid_buffer = (ctypes.c_int * num_pids)()
            num_pids = libc.proc_listallpids(ctypes.byref(pid_buffer), num_pids * 4)

            # proc_bsdinfo structure for getting process info
            class proc_bsdinfo(ctypes.Structure):
                _fields_ = [
                    ("pbi_status", ctypes.c_uint),
                    ("pbi_xstatus", ctypes.c_uint),
                    ("pbi_pid", ctypes.c_uint),
                    ("pbi_ppid", ctypes.c_uint),
                    ("pbi_uid", ctypes.c_uint),
                    ("pbi_gid", ctypes.c_uint),
                    ("pbi_ruid", ctypes.c_uint),
                    ("pbi_rgid", ctypes.c_uint),
                    ("pbi_svuid", ctypes.c_uint),
                    ("pbi_svgid", ctypes.c_uint),
                    ("rfu_1", ctypes.c_uint),
                    ("pbi_comm", ctypes.c_char * 16),
                    ("pbi_name", ctypes.c_char * 32),
                    ("pbi_nfiles", ctypes.c_uint),
                    ("pbi_pgid", ctypes.c_uint),
                    ("pbi_pjobc", ctypes.c_uint),
                    ("e_tdev", ctypes.c_uint),
                    ("e_tpgid", ctypes.c_uint),
                    ("pbi_starttime", ctypes.c_ulonglong),
                ]

            PROC_PIDTBSDINFO = 3

            for i in range(num_pids):
                pid = pid_buffer[i]
                if pid <= 0:
                    continue

                info = proc_bsdinfo()
                ret = libc.proc_pidinfo(
                    pid, PROC_PIDTBSDINFO, 0, ctypes.byref(info), ctypes.sizeof(info)
                )
                if ret <= 0:
                    continue

                proc_info = {
                    "pid": info.pbi_pid,
                    "ppid": info.pbi_ppid,
                    "name": info.pbi_comm.decode("utf-8", errors="replace")
                    if info.pbi_comm
                    else "",
                }

                # Try to get full path via proc_pidpath
                try:
                    path_buffer = ctypes.create_string_buffer(4096)
                    ret = libc.proc_pidpath(pid, path_buffer, 4096)
                    if ret > 0:
                        proc_info["exe_path"] = path_buffer.value.decode(
                            "utf-8", errors="replace"
                        )
                except Exception:
                    pass

                processes.append(proc_info)
        except Exception:
            pass

        return processes

    def _require_param(self, params, key):
        if key not in params:
            raise PermissionError("Missing parameter: %s" % key)
        value = params[key]
        if not isinstance(value, str):
            value = str(value)
        if not self._SAFE_TOKEN.match(value):
            raise PermissionError("Invalid characters in parameter: %s" % key)
        return value

    def _require_int_param(self, params, key, minimum=1, maximum=10000):
        if key not in params:
            raise PermissionError("Missing parameter: %s" % key)
        try:
            value = int(params[key])
        except Exception:
            raise PermissionError("Parameter must be integer: %s" % key)
        if value < minimum or value > maximum:
            raise PermissionError("Parameter out of allowed range: %s" % key)
        return value

    def _is_windows(self):
        return os.name == "nt"

    def _block_if_not_windows(self, action):
        if not self._is_windows():
            raise PermissionError("Action %s is supported only on Windows nodes." % action)

    def _ad_get_user_details(self, params, timeout):
        self._block_if_not_windows("get_user_details")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user], timeout)

    def _ad_enumerate_group_membership(self, params, timeout):
        self._block_if_not_windows("enumerate_group_membership")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user], timeout)

    def _ad_reset_password(self, params, timeout):
        self._block_if_not_windows("reset_password")
        user = self._require_param(params, "username")
        new_password = self._require_param(params, "new_password")
        return self._exec_command_structured(["net", "user", user, new_password], timeout)

    def _ad_disable_account(self, params, timeout):
        self._block_if_not_windows("disable_account")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user, "/active:no"], timeout)

    def _ad_enable_account(self, params, timeout):
        self._block_if_not_windows("enable_account")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user, "/active:yes"], timeout)

    def _ad_retrieve_last_logon(self, params, timeout):
        self._block_if_not_windows("retrieve_last_logon")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user], timeout)

    def _ad_audit_privileged_group_membership(self, params, timeout):
        self._block_if_not_windows("audit_privileged_group_membership")
        return self._exec_command_structured(["net", "localgroup", "Administrators"], timeout)

    def _ad_replication_drift_probe(self, params, timeout):
        del params
        self._block_if_not_windows("ad_replication_drift_probe")
        checks = {}
        output = []
        for key, cmd in (
            ("replsummary", ["repadmin", "/replsummary"]),
            ("showrepl", ["repadmin", "/showrepl", "*"]),
        ):
            result, raw = self._exec_command_structured(cmd, timeout)
            checks[key] = result
            output.append(raw)
        return {"checks": checks}, "\n".join(output)

    def _ad_gpo_startup_script_view(self, params, timeout):
        self._block_if_not_windows("gpo_startup_script_view")
        limit = self._require_int_param(params, "limit", minimum=1, maximum=200) if "limit" in params else 30
        command = (
            "$domain=$env:USERDNSDOMAIN;"
            "if(-not $domain){Write-Output 'No USERDNSDOMAIN found';exit 1};"
            "$root='\\\\' + $domain + '\\SYSVOL\\' + $domain + '\\Policies';"
            "$files=Get-ChildItem -Path $root -Recurse -ErrorAction SilentlyContinue | "
            "Where-Object { -not $_.PSIsContainer -and ($_.FullName -match 'Scripts') };"
            "$files=$files | Select-Object -First %d;"
            "$results=@();"
            "foreach($f in $files){"
            "$content=Get-Content -Path $f.FullName -ErrorAction SilentlyContinue | Select-Object -First 200;"
            "$results += [PSCustomObject]@{path=$f.FullName;content=$content};"
            "};"
            "$results | ConvertTo-Json -Depth 5"
        ) % limit
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-Command", command],
            timeout,
        )

    def _endpoint_wmic_query(self, params, timeout):
        query = self._require_param(params, "query")
        if query not in self.ALLOWED_WMIC_QUERIES:
            raise PermissionError("WMIC query is not allowlisted.")
        self._block_if_not_windows("wmic_query")
        return self._exec_command_structured(self.ALLOWED_WMIC_QUERIES[query], timeout)

    def _endpoint_running_processes(self, params, timeout):
        self._block_if_not_windows("running_processes")
        return self._exec_command_structured(["tasklist"], timeout)

    def _endpoint_list_processes(self, params, timeout):
        del params
        del timeout
        """List all processes using native OS APIs.

        Uses platform-specific APIs to avoid PATH dependency issues:
        - Windows: CreateToolhelp32Snapshot via ctypes
        - Linux: /proc filesystem
        - macOS: proc_listallpids via libc

        Returns structured process data including pid, ppid, name, and exe_path.
        """
        processes = []
        errors = []

        if self._is_windows():
            try:
                processes = self._list_processes_windows_api()
            except Exception as e:
                errors.append(str(e))
        elif sys.platform == "darwin":
            try:
                processes = self._list_processes_macos_libc()
            except Exception as e:
                errors.append(str(e))
        elif sys.platform in ("linux", "linux2"):
            try:
                processes = self._list_processes_linux_proc()
            except Exception as e:
                errors.append(str(e))

        # Sort by pid for consistent output
        processes.sort(key=lambda p: p.get("pid", 0))

        result = {
            "count": len(processes),
            "processes": processes,
        }
        if errors:
            result["errors"] = errors

        raw_lines = ["pid\tppid\tname\texe_path"]
        for p in processes:
            raw_lines.append(
                f"{p.get('pid', '?')}\t{p.get('ppid', '?')}\t{p.get('name', '?')}\t{p.get('exe_path', '')}"
            )

        return result, "\n".join(raw_lines)

    def _endpoint_logged_in_users(self, params, timeout):
        self._block_if_not_windows("logged_in_users")
        return self._exec_command_structured(["query", "user"], timeout)

    def _endpoint_list_users(self, params, timeout):
        del params
        if self._is_windows():
            return self._exec_command_structured(["net", "user"], timeout)
        return self._exec_command_structured(["getent", "passwd"], timeout)

    def _endpoint_logged_in_users_login_time(self, params, timeout):
        del params
        if self._is_windows():
            cmd = [
                "powershell",
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                "quser | Out-String",
            ]
            return self._exec_command_structured(cmd, timeout)
        return self._exec_command_structured(["who", "-u"], timeout)

    def _endpoint_installed_software(self, params, timeout):
        self._block_if_not_windows("installed_software")
        return self._exec_command_structured(["wmic", "product", "get", "name,version"], timeout)

    def _endpoint_services_status(self, params, timeout):
        self._block_if_not_windows("services_status")
        return self._exec_command_structured(["sc", "query"], timeout)

    def _endpoint_scheduled_tasks(self, params, timeout):
        self._block_if_not_windows("scheduled_tasks")
        return self._exec_command_structured(["schtasks", "/query", "/fo", "csv"], timeout)

    def _endpoint_startup_programs(self, params, timeout):
        """Enumerate ALL programs configured to run at startup or logon.

        On Windows this covers:
          - Registry Run / RunOnce / RunServices keys (HKLM + HKCU, 32 & 64-bit)
          - AppInit_DLLs
          - Shell extensions / browser helper objects
          - Startup folders (all-users and current-user)
          - Enabled scheduled tasks with a boot/logon trigger
          - Auto-start services (Automatic / Automatic-delayed)
          - WMI event subscriptions (__EventConsumer / __FilterToConsumerBinding)

        On Linux this covers:
          - systemd enabled units (user + system)
          - SysV init.d / rc*.d symlinks
          - /etc/xdg/autostart and ~/.config/autostart .desktop files
          - @reboot entries from all crontabs
          - /etc/profile.d scripts
        """
        del params
        if self._is_windows():
            ps = r"""
$results = [System.Collections.Generic.List[PSCustomObject]]::new()

# Helper
function Add-Entry($Source, $Name, $Command, $Location='') {
    $results.Add([PSCustomObject]@{
        source   = $Source
        name     = $Name
        command  = $Command
        location = $Location
    })
}

# --- Registry Run keys ---
$runKeys = @(
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServices',
    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunServicesOnce',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
    'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
    'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows'
)
foreach ($key in $runKeys) {
    if (Test-Path $key) {
        $props = Get-ItemProperty -Path $key -ErrorAction SilentlyContinue
        if ($props) {
            $props.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | ForEach-Object {
                Add-Entry 'registry_run' $_.Name $_.Value $key
            }
        }
    }
}

# --- AppInit_DLLs ---
$aiKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\AppCertDlls'
if (Test-Path $aiKey) {
    (Get-ItemProperty $aiKey -ErrorAction SilentlyContinue).PSObject.Properties |
        Where-Object { $_.Name -notmatch '^PS' } |
        ForEach-Object { Add-Entry 'appcert_dlls' $_.Name $_.Value $aiKey }
}
$aiKey2 = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows'
if (Test-Path $aiKey2) {
    $v = (Get-ItemProperty $aiKey2 -ErrorAction SilentlyContinue).AppInit_DLLs
    if ($v) { Add-Entry 'appinit_dlls' 'AppInit_DLLs' $v $aiKey2 }
}

# --- Winlogon ---
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
if (Test-Path $wlKey) {
    $wl = Get-ItemProperty $wlKey -ErrorAction SilentlyContinue
    foreach ($field in @('Userinit','Shell','TaskMan')) {
        if ($wl.$field) { Add-Entry 'winlogon' $field $wl.$field $wlKey }
    }
}

# --- Startup folders ---
$startupPaths = @(
    [System.Environment]::GetFolderPath('CommonStartup'),
    [System.Environment]::GetFolderPath('Startup')
)
foreach ($sp in $startupPaths) {
    if ($sp -and (Test-Path $sp)) {
        Get-ChildItem $sp -ErrorAction SilentlyContinue | ForEach-Object {
            Add-Entry 'startup_folder' $_.Name $_.FullName $sp
        }
    }
}

# --- Scheduled tasks with boot/logon trigger ---
Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object {
    $_.Triggers | Where-Object { $_ -is [Microsoft.Management.Infrastructure.CimInstance] -and
        ($_.CimClass.CimClassName -match 'MSFT_TaskBootTrigger|MSFT_TaskLogonTrigger') }
} | ForEach-Object {
    $action = ($_.Actions | Select-Object -First 1)
    $cmd = if ($action) { "$($action.Execute) $($action.Arguments)".Trim() } else { '' }
    Add-Entry 'scheduled_task' $_.TaskName $cmd $_.TaskPath
}

# --- Auto-start services ---
Get-CimInstance Win32_Service -Filter "StartMode='Auto'" -ErrorAction SilentlyContinue |
    ForEach-Object { Add-Entry 'service_auto' $_.Name $_.PathName $_.StartMode }

# --- WMI event subscriptions ---
Get-CimInstance -Namespace root\subscription -ClassName __EventConsumer -ErrorAction SilentlyContinue |
    ForEach-Object { Add-Entry 'wmi_consumer' $_.Name ($_.CommandLineTemplate -or $_.ScriptText) 'WMI' }

$results | ConvertTo-Json -Depth 3 -Compress
"""
            return self._exec_command_structured(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
                timeout,
            )
        else:
            # Linux: gather from systemd, SysV, cron, autostart, profile.d
            sh = r"""
python3 - << 'PYEOF'
import json, os, subprocess, glob, re

results = []

def add(source, name, command, location=''):
    results.append({'source': source, 'name': name, 'command': command, 'location': location})

# systemd system-wide enabled units
try:
    out = subprocess.check_output(['systemctl', 'list-unit-files', '--state=enabled',
                                   '--no-pager', '--no-legend'], text=True, timeout=15)
    for line in out.splitlines():
        parts = line.split()
        if parts:
            add('systemd_system', parts[0], '', '/etc/systemd/system')
except Exception:
    pass

# systemd user enabled units
try:
    out = subprocess.check_output(['systemctl', '--user', 'list-unit-files', '--state=enabled',
                                   '--no-pager', '--no-legend'], text=True, timeout=15)
    for line in out.splitlines():
        parts = line.split()
        if parts:
            add('systemd_user', parts[0], '', '~/.config/systemd/user')
except Exception:
    pass

# SysV rc*.d symlinks
for path in glob.glob('/etc/rc*.d/S*'):
    add('sysv_init', os.path.basename(path), '', os.path.dirname(path))

# /etc/xdg/autostart and ~/.config/autostart
for base in ['/etc/xdg/autostart', os.path.expanduser('~/.config/autostart')]:
    for f in glob.glob(os.path.join(base, '*.desktop')):
        name = os.path.basename(f)
        cmd = ''
        try:
            for line in open(f, errors='replace'):
                if line.startswith('Exec='):
                    cmd = line[5:].strip(); break
        except Exception:
            pass
        add('autostart_desktop', name, cmd, base)

# @reboot crontab entries
try:
    out = subprocess.check_output(['crontab', '-l'], text=True, stderr=subprocess.DEVNULL, timeout=10)
    for line in out.splitlines():
        if line.strip().startswith('@reboot'):
            add('cron_reboot', '@reboot', line.strip(), 'crontab -l')
except Exception:
    pass
for cf in glob.glob('/etc/cron.d/*') + glob.glob('/var/spool/cron/crontabs/*'):
    try:
        for line in open(cf, errors='replace'):
            if re.match(r'\s*@reboot\s', line):
                add('cron_reboot', os.path.basename(cf), line.strip(), cf)
    except Exception:
        pass

# /etc/profile.d scripts
for f in glob.glob('/etc/profile.d/*.sh'):
    add('profile_d', os.path.basename(f), '', '/etc/profile.d')

print(json.dumps(results, indent=2))
PYEOF
"""
            return self._exec_command_structured(
                ["bash", "-c", sh.strip()],
                timeout,
            )

    def _endpoint_open_ports(self, params, timeout):
        """List all listening/open ports with associated process name and PID.

        On Windows this uses the IP Helper API (GetExtendedTcpTable /
        GetExtendedUdpTable) via ctypes so no child process is spawned.
        On Linux it falls back to ``ss -tulnp``.
        """
        del params
        if self._is_windows():
            ps = r"""
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()

# TCP listening ports
$tcpConns = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue
foreach ($c in $tcpConns) {
    $proc = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue
    $rows.Add([PSCustomObject]@{
        protocol      = 'TCP'
        local_address = $c.LocalAddress
        local_port    = $c.LocalPort
        state         = $c.State.ToString()
        pid           = $c.OwningProcess
        process_name  = if ($proc) { $proc.Name } else { '<unknown>' }
    })
}

# UDP open ports
$udpConns = Get-NetUDPEndpoint -ErrorAction SilentlyContinue
foreach ($u in $udpConns) {
    $proc = Get-Process -Id $u.OwningProcess -ErrorAction SilentlyContinue
    $rows.Add([PSCustomObject]@{
        protocol      = 'UDP'
        local_address = $u.LocalAddress
        local_port    = $u.LocalPort
        state         = 'OPEN'
        pid           = $u.OwningProcess
        process_name  = if ($proc) { $proc.Name } else { '<unknown>' }
    })
}

$rows | Sort-Object protocol, local_port | ConvertTo-Json -Depth 2 -Compress
"""
            return self._exec_command_structured(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
                timeout,
            )
        else:
            return self._exec_command_structured(
                ["ss", "-tulnp"],
                timeout,
            )

    def _endpoint_active_connections(self, params, timeout):
        """List all current network connections (all states) with process info.

        Shows established, time-wait, close-wait, etc. connections along with
        the remote address and the owning process name and PID.
        """
        del params
        if self._is_windows():
            ps = r"""
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()

$states = @('Established','TimeWait','CloseWait','LastAck','FinWait1','FinWait2','SynSent','SynReceived')
$conns  = Get-NetTCPConnection -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Listen' }
foreach ($c in $conns) {
    $proc = Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue
    $rows.Add([PSCustomObject]@{
        protocol        = 'TCP'
        local_address   = $c.LocalAddress
        local_port      = $c.LocalPort
        remote_address  = $c.RemoteAddress
        remote_port     = $c.RemotePort
        state           = $c.State.ToString()
        pid             = $c.OwningProcess
        process_name    = if ($proc) { $proc.Name } else { '<unknown>' }
    })
}

$rows | Sort-Object state, local_port | ConvertTo-Json -Depth 2 -Compress
"""
            return self._exec_command_structured(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
                timeout,
            )
        else:
            return self._exec_command_structured(
                ["ss", "-tanp"],
                timeout,
            )

    def _endpoint_dns_query_history(self, params, timeout):
        """Return a unique list of DNS hostnames queried in the past N hours.

        Reads from the Microsoft-Windows-DNS-Client/Operational event log
        (Event ID 3006 – DNS query) on Windows.

        Parameters
        ----------
        hours : int
            How far back to look (1–168).  Defaults to 24.
        """
        hours = 24
        try:
            hours = int(params.get("hours", 24))
            if hours < 1:
                hours = 1
            if hours > 168:
                hours = 168
        except Exception:
            pass

        if self._is_windows():
            ps = r"""
$hours   = %d
$cutoff  = (Get-Date).AddHours(-$hours)
$log     = 'Microsoft-Windows-DNS-Client/Operational'

try {
    $events = Get-WinEvent -LogName $log -ErrorAction Stop |
              Where-Object { $_.Id -eq 3006 -and $_.TimeCreated -ge $cutoff }
} catch {
    # Log may be disabled – attempt to enable it
    $logObj = Get-WinEvent -ListLog $log -ErrorAction SilentlyContinue
    if ($logObj -and -not $logObj.IsEnabled) {
        $logObj.IsEnabled = $true
        $logObj.SaveChanges()
        Write-Output '{"note":"DNS Client event log was disabled. Enabled now – queries will appear in future runs.","hosts":[]}'
    } else {
        Write-Output '{"note":"DNS Client event log unavailable.","hosts":[]}'
    }
    exit 0
}

$hosts = $events | ForEach-Object {
    # Event data field 0 is the queried name
    $_.Properties[0].Value
} | Where-Object { $_ } | Sort-Object -Unique

[PSCustomObject]@{
    hours_back   = $hours
    total_unique = $hosts.Count
    hosts        = @($hosts)
} | ConvertTo-Json -Depth 2 -Compress
""" % hours
            return self._exec_command_structured(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
                timeout,
            )
        else:
            # Linux: parse systemd-resolved or dnsmasq logs
            sh = r"""
python3 - << 'PYEOF'
import subprocess, re, json, datetime

hours = %d
cutoff = datetime.datetime.now() - datetime.timedelta(hours=hours)
hosts = set()

# Try journalctl for systemd-resolved queries
try:
    since = cutoff.strftime('%%Y-%%m-%%d %%H:%%M:%%S')
    out = subprocess.check_output(
        ['journalctl', '-u', 'systemd-resolved', '--since', since, '--no-pager', '-q'],
        text=True, timeout=30, stderr=subprocess.DEVNULL
    )
    for line in out.splitlines():
        m = re.search(r'query\s+(\S+)\s+IN\s+', line)
        if m:
            hosts.add(m.group(1).rstrip('.'))
except Exception:
    pass

result = {
    'hours_back': hours,
    'total_unique': len(hosts),
    'hosts': sorted(hosts),
}
print(json.dumps(result))
PYEOF
""" % hours
            return self._exec_command_structured(
                ["bash", "-c", sh.strip()],
                timeout,
            )

    def _endpoint_run_powershell(self, params, timeout):
        """Execute an arbitrary PowerShell script and return stdout/stderr.

        This provides dynamic tooling for cases not covered by built-in actions.
        Command execution must be enabled in config (``enable_command_execution``).

        Parameters
        ----------
        script : str
            The PowerShell script text to execute.  Passed via ``-Command``
            in a non-interactive, no-profile session.
        """
        self._block_if_not_windows("run_powershell")
        if not self.enable_command_execution:
            raise PermissionError("Command execution is disabled by policy.")

        script = params.get("script")
        if not script or not isinstance(script, str):
            raise PermissionError("Missing or invalid 'script' parameter.")
        if "\x00" in script:
            raise PermissionError("Null bytes are not allowed in script.")

        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #1 – PowerShell Script Block History
    # -----------------------------------------------------------------------
    def _endpoint_powershell_script_block_history(self, params, timeout):
        """Extract full PowerShell script text logged via EID 4104.

        Captures every script block executed on the host (including obfuscated
        one-liners) from the Microsoft-Windows-PowerShell/Operational log.
        If logging was disabled the action enables it automatically so future
        sessions are captured.

        Parameters
        ----------
        hours : int   How far back to search (1-168, default 24).
        limit : int   Maximum entries returned (1-5000, default 500).
        """
        self._block_if_not_windows("powershell_script_block_history")
        try:
            hours = max(1, min(168, int(params.get("hours", 24))))
        except Exception:
            hours = 24
        try:
            limit = max(1, min(5000, int(params.get("limit", 500))))
        except Exception:
            limit = 500

        ps = r"""
$hours  = %d
$limit  = %d
$cutoff = (Get-Date).AddHours(-$hours)
$log    = 'Microsoft-Windows-PowerShell/Operational'

$logObj = Get-WinEvent -ListLog $log -ErrorAction SilentlyContinue
if ($logObj -and -not $logObj.IsEnabled) {
    $logObj.IsEnabled = $true
    $logObj.SaveChanges()
    $warn = 'Script block logging was disabled and has now been enabled. No historical entries are available for this run.'
} else { $warn = '' }

$entries = @()
try {
    $evts = Get-WinEvent -LogName $log -ErrorAction Stop |
            Where-Object { $_.Id -eq 4104 -and $_.TimeCreated -ge $cutoff } |
            Select-Object -First $limit
    $entries = $evts | ForEach-Object {
        $text = [string]$_.Properties[2].Value
        if ($text.Length -gt 8192) { $text = $text.Substring(0, 8192) + '...<truncated>' }
        [PSCustomObject]@{
            timestamp       = $_.TimeCreated.ToUniversalTime().ToString('o')
            script_block_id = [string]$_.Properties[0].Value
            msg_number      = [string]$_.Properties[1].Value
            script_text     = $text
            script_path     = [string]$_.Properties[4].Value
            user_sid        = if ($_.UserId) { $_.UserId.Value } else { '' }
            pid             = $_.ProcessId
            machine         = $_.MachineName
        }
    }
} catch {
    $warn += ' Event log query error: ' + $_.ToString()
}

[PSCustomObject]@{
    hours_back     = $hours
    total_returned = @($entries).Count
    warning        = $warn
    entries        = @($entries)
} | ConvertTo-Json -Depth 4 -Compress
""" % (hours, limit)
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #2 – Prefetch Analysis
    # -----------------------------------------------------------------------
    def _endpoint_prefetch_analysis(self, params, timeout):
        """Parse Windows Prefetch files for full program execution history.

        Recovers the executable name, run count, and up to 8 last-run
        timestamps from every .pf file in C:\\Windows\\Prefetch.  Handles
        MAM-compressed Win10 prefetch via RtlDecompressBufferEx (ntdll).

        Parameters
        ----------
        filter : str   Optional substring to match against executable names.
        """
        self._block_if_not_windows("prefetch_analysis")

        import struct
        import glob as _glob
        import datetime as _dt

        exe_filter = str(params.get("filter", "")).strip().lower()
        PREFETCH_DIR = r"C:\Windows\Prefetch"

        def _ft_to_iso(val):
            if val <= 0:
                return None
            try:
                us = (val - 116444736000000000) // 10
                if us < 0:
                    return None
                dt = _dt.datetime(1970, 1, 1) + _dt.timedelta(microseconds=us)
                return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
            except Exception:
                return None

        def _expand_mam(data):
            if len(data) < 8 or data[:3] != b"MAM":
                return None
            import ctypes
            uncomp_size = struct.unpack_from("<I", data, 4)[0]
            compressed = (ctypes.c_char * len(data[8:]))(*data[8:])
            ntdll = ctypes.WinDLL("ntdll.dll")
            ntdll.RtlGetCompressionWorkSpaceSize.restype = ctypes.c_long
            ntdll.RtlGetCompressionWorkSpaceSize.argtypes = [
                ctypes.c_ushort,
                ctypes.POINTER(ctypes.c_ulong),
                ctypes.POINTER(ctypes.c_ulong),
            ]
            ntdll.RtlDecompressBufferEx.restype = ctypes.c_long
            ntdll.RtlDecompressBufferEx.argtypes = [
                ctypes.c_ushort,
                ctypes.c_char_p, ctypes.c_ulong,
                ctypes.c_char_p, ctypes.c_ulong,
                ctypes.POINTER(ctypes.c_ulong),
                ctypes.c_void_p,
            ]
            ws1 = ctypes.c_ulong(0)
            ws2 = ctypes.c_ulong(0)
            XPRESS_HUFF = ctypes.c_ushort(0x0102)
            if ntdll.RtlGetCompressionWorkSpaceSize(XPRESS_HUFF, ctypes.byref(ws1), ctypes.byref(ws2)) != 0:
                return None
            workspace = ctypes.create_string_buffer(ws1.value)
            out_buf = ctypes.create_string_buffer(uncomp_size)
            final_sz = ctypes.c_ulong(0)
            ret = ntdll.RtlDecompressBufferEx(
                XPRESS_HUFF,
                out_buf, ctypes.c_ulong(uncomp_size),
                compressed, ctypes.c_ulong(len(data) - 8),
                ctypes.byref(final_sz),
                workspace,
            )
            return bytes(out_buf.raw[: final_sz.value]) if ret == 0 else None

        def _parse_scca(data):
            if len(data) < 84 or data[4:8] != b"SCCA":
                return None
            version = struct.unpack_from("<I", data, 0)[0]
            r = {"version": version, "run_count": 0, "last_run_times": []}
            try:
                if version == 17 and len(data) >= 0x94:
                    r["run_count"] = struct.unpack_from("<I", data, 0x90)[0]
                    t = _ft_to_iso(struct.unpack_from("<q", data, 0x78)[0])
                    if t: r["last_run_times"].append(t)
                elif version == 23 and len(data) >= 0x9C:
                    r["run_count"] = struct.unpack_from("<I", data, 0x98)[0]
                    t = _ft_to_iso(struct.unpack_from("<q", data, 0x78)[0])
                    if t: r["last_run_times"].append(t)
                elif version in (26, 30) and len(data) >= 0xD4:
                    r["run_count"] = struct.unpack_from("<I", data, 0xD0)[0]
                    for i in range(8):
                        off = 0x80 + i * 8
                        if off + 8 <= len(data):
                            t = _ft_to_iso(struct.unpack_from("<q", data, off)[0])
                            if t: r["last_run_times"].append(t)
            except Exception:
                pass
            return r

        entries = []
        try:
            pf_files = _glob.glob(os.path.join(PREFETCH_DIR, "*.pf"))
        except Exception as exc:
            out = json.dumps({"error": str(exc), "entries": []})
            return ({"command": "prefetch_analysis", "stdout": out, "stderr": "", "exit_code": 1}, out)

        import datetime as _dt2
        for pf_path in sorted(pf_files):
            basename = os.path.basename(pf_path)
            name_no_ext = os.path.splitext(basename)[0]
            parts = name_no_ext.rsplit("-", 1)
            exe_name = parts[0] if len(parts) == 2 else name_no_ext
            pf_hash = parts[1] if len(parts) == 2 else ""
            if exe_filter and exe_filter not in exe_name.lower():
                continue
            try:
                st = os.stat(pf_path)
                last_mod = _dt2.datetime.utcfromtimestamp(st.st_mtime).strftime("%Y-%m-%dT%H:%M:%SZ")
                sz = st.st_size
            except Exception:
                last_mod, sz = "", 0
            entry = {
                "executable": exe_name, "prefetch_hash": pf_hash,
                "pf_file": pf_path, "size_bytes": sz,
                "last_modified_utc": last_mod,
                "version": None, "run_count": None,
                "last_run_times": [], "parse_status": "ok",
            }
            try:
                raw_bytes = open(pf_path, "rb").read()
            except PermissionError:
                entry["parse_status"] = "permission_denied"
                entries.append(entry)
                continue
            except Exception as exc:
                entry["parse_status"] = "read_error: %s" % exc
                entries.append(entry)
                continue
            if raw_bytes[:3] == b"MAM":
                try:
                    dec = _expand_mam(raw_bytes)
                    if dec:
                        raw_bytes = dec
                    else:
                        entry["parse_status"] = "mam_decompression_failed"
                        entries.append(entry)
                        continue
                except Exception as exc:
                    entry["parse_status"] = "mam_error: %s" % exc
                    entries.append(entry)
                    continue
            parsed = _parse_scca(raw_bytes)
            if parsed:
                entry.update({"version": parsed["version"], "run_count": parsed["run_count"],
                               "last_run_times": parsed["last_run_times"]})
            else:
                entry["parse_status"] = "parse_failed"
            entries.append(entry)

        entries.sort(key=lambda e: (e.get("last_run_times") or [""])[0] or e.get("last_modified_utc", ""), reverse=True)
        out = json.dumps({"total": len(entries), "prefetch_dir": PREFETCH_DIR, "entries": entries}, indent=2)
        return ({"command": "prefetch_analysis", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #3 – Process Module / Loaded DLL List
    # -----------------------------------------------------------------------
    def _endpoint_process_modules(self, params, timeout):
        """List every DLL loaded into a process and flag suspicious ones.

        Suspicious flags are set for modules loaded from user-writable or
        temporary paths (AppData, Downloads, Temp, ProgramData) and for
        modules whose file name does not match a legitimate Windows system DLL
        location (System32, SysWOW64, Program Files).

        Parameters
        ----------
        pid : int   Target process ID (required).
        """
        self._block_if_not_windows("process_modules")
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)

        ps = r"""
$pid = %d
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
if (-not $proc) {
    Write-Output ('{"error":"Process ' + $pid + ' not found","modules":[]}')
    exit 0
}

$suspiciousPatterns = @(
    '\\AppData\\', '\\Temp\\', '\\tmp\\', '\\Downloads\\',
    '\\Desktop\\', '\\ProgramData\\', '\\Users\\Public\\'
)
$systemPaths = @(
    $env:SystemRoot + '\System32',
    $env:SystemRoot + '\SysWOW64',
    $env:SystemRoot + '\',
    ${env:ProgramFiles},
    ${env:ProgramFiles(x86)}
)

$modules = try { $proc.Modules } catch { @() }

$result = $modules | ForEach-Object {
    $path = $_.FileName
    $suspicious = $false
    $suspect_reason = ''

    foreach ($pat in $suspiciousPatterns) {
        if ($path -like "*$pat*") {
            $suspicious = $true
            $suspect_reason += "loaded_from_user_writable_path;"
            break
        }
    }

    $inSystemPath = $false
    foreach ($sp in $systemPaths) {
        if ($sp -and $path -like "$sp*") { $inSystemPath = $true; break }
    }
    if (-not $inSystemPath -and -not $suspicious) {
        $suspicious = $true
        $suspect_reason += "not_in_standard_system_path;"
    }

    # Check if file is signed
    $signed = 'unknown'
    try {
        $sig = Get-AuthenticodeSignature -FilePath $path -ErrorAction SilentlyContinue
        if ($sig) { $signed = $sig.Status.ToString() }
    } catch {}

    [PSCustomObject]@{
        module_name   = $_.ModuleName
        file_path     = $path
        base_address  = '0x{0:X}' -f $_.BaseAddress.ToInt64()
        module_size   = $_.ModuleMemorySize
        file_version  = $_.FileVersionInfo.FileVersion
        company       = $_.FileVersionInfo.CompanyName
        signed        = $signed
        suspicious    = $suspicious
        suspect_reason = $suspect_reason.TrimEnd(';')
    }
}

[PSCustomObject]@{
    pid            = $pid
    process_name   = $proc.Name
    total_modules  = @($result).Count
    suspicious_count = @($result | Where-Object { $_.suspicious }).Count
    modules        = @($result)
} | ConvertTo-Json -Depth 3 -Compress
""" % pid
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #4 – Lateral Movement Artifacts
    # -----------------------------------------------------------------------
    def _endpoint_lateral_movement_artifacts(self, params, timeout):
        """Extract lateral movement evidence from the Security event log.

        Queries EID 4624 (logon), 4648 (explicit-credential logon), 4776
        (NTLM auth), 4625 (failed logon), and 4672 (privileged logon) filtered
        to network (Type 3) and remote-interactive (Type 10) logon types,
        giving a clear timeline of inbound lateral movement.

        Parameters
        ----------
        hours : int   How far back to search (1-720, default 48).
        """
        self._block_if_not_windows("lateral_movement_artifacts")
        try:
            hours = max(1, min(720, int(params.get("hours", 48))))
        except Exception:
            hours = 48

        ps = r"""
$hours  = %d
$cutoff = (Get-Date).AddHours(-$hours)
$log    = 'Security'
$eids   = @(4624, 4648, 4776, 4625, 4672)

try {
    $evts = Get-WinEvent -LogName $log -ErrorAction Stop |
            Where-Object { $_.Id -in $eids -and $_.TimeCreated -ge $cutoff }
} catch {
    Write-Output ('{"error":"' + $_.ToString() + '","events":[]}')
    exit 0
}

$events = $evts | ForEach-Object {
    $e   = $_
    $p   = $e.Properties
    $obj = [PSCustomObject]@{
        timestamp    = $e.TimeCreated.ToUniversalTime().ToString('o')
        event_id     = $e.Id
        logon_type   = $null
        source_ip    = ''
        source_host  = ''
        target_user  = ''
        target_domain = ''
        auth_package = ''
        logon_id     = ''
        status       = ''
        substatus    = ''
        process_name = ''
        lateral      = $false
    }

    switch ($e.Id) {
        4624 {
            $obj.logon_type    = if ($p.Count -gt 8)  { [string]$p[8].Value  } else { '' }
            $obj.target_user   = if ($p.Count -gt 5)  { [string]$p[5].Value  } else { '' }
            $obj.target_domain = if ($p.Count -gt 6)  { [string]$p[6].Value  } else { '' }
            $obj.auth_package  = if ($p.Count -gt 10) { [string]$p[10].Value } else { '' }
            $obj.logon_id      = if ($p.Count -gt 7)  { '{0:X}' -f $p[7].Value.ToInt64() } else { '' }
            $obj.source_ip     = if ($p.Count -gt 18) { [string]$p[18].Value } else { '' }
            $obj.source_host   = if ($p.Count -gt 19) { [string]$p[19].Value } else { '' }
            $obj.process_name  = if ($p.Count -gt 17) { [string]$p[17].Value } else { '' }
            $obj.lateral       = $obj.logon_type -in @('3','10')
        }
        4648 {
            $obj.target_user   = if ($p.Count -gt 5)  { [string]$p[5].Value  } else { '' }
            $obj.target_domain = if ($p.Count -gt 6)  { [string]$p[6].Value  } else { '' }
            $obj.source_ip     = if ($p.Count -gt 12) { [string]$p[12].Value } else { '' }
            $obj.process_name  = if ($p.Count -gt 11) { [string]$p[11].Value } else { '' }
            $obj.lateral       = $true
        }
        4776 {
            $obj.target_user   = if ($p.Count -gt 1)  { [string]$p[1].Value  } else { '' }
            $obj.source_host   = if ($p.Count -gt 2)  { [string]$p[2].Value  } else { '' }
            $obj.auth_package  = if ($p.Count -gt 0)  { [string]$p[0].Value  } else { '' }
            $obj.status        = if ($p.Count -gt 3)  { '0x{0:X}' -f $p[3].Value } else { '' }
            $obj.lateral       = $true
        }
        4625 {
            $obj.logon_type    = if ($p.Count -gt 10) { [string]$p[10].Value } else { '' }
            $obj.target_user   = if ($p.Count -gt 5)  { [string]$p[5].Value  } else { '' }
            $obj.source_ip     = if ($p.Count -gt 19) { [string]$p[19].Value } else { '' }
            $obj.status        = if ($p.Count -gt 7)  { '0x{0:X}' -f $p[7].Value } else { '' }
            $obj.substatus     = if ($p.Count -gt 8)  { '0x{0:X}' -f $p[8].Value } else { '' }
            $obj.lateral       = $obj.logon_type -in @('3','10')
        }
        4672 {
            $obj.target_user   = if ($p.Count -gt 1)  { [string]$p[1].Value  } else { '' }
            $obj.target_domain = if ($p.Count -gt 2)  { [string]$p[2].Value  } else { '' }
            $obj.logon_id      = if ($p.Count -gt 3)  { '{0:X}' -f $p[3].Value.ToInt64() } else { '' }
            $obj.lateral       = $false
        }
    }
    $obj
}

$lateral   = @($events | Where-Object { $_.lateral })
$all_evts  = @($events)

[PSCustomObject]@{
    hours_back          = $hours
    total_events        = $all_evts.Count
    lateral_move_count  = $lateral.Count
    events              = $all_evts
} | ConvertTo-Json -Depth 3 -Compress
""" % hours
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #5 – LSASS Access Indicators
    # -----------------------------------------------------------------------
    def _endpoint_lsass_access_indicators(self, params, timeout):
        """Detect credential-dumping activity targeting LSASS.

        Checks: PPL (Protected Process Light) protection status, Security
        event log EID 4656/4663 object-access events against lsass.exe, and
        enumerates processes with suspicious names known to be used for
        LSASS dumping (mimikatz, procdump, comsvcs, etc.).

        Parameters
        ----------
        hours : int   Event log look-back window (1-168, default 24).
        """
        self._block_if_not_windows("lsass_access_indicators")
        try:
            hours = max(1, min(168, int(params.get("hours", 24))))
        except Exception:
            hours = 24

        ps = r"""
$hours  = %d
$cutoff = (Get-Date).AddHours(-$hours)

# --- PPL status ---
$pplKey  = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
$pplVal  = (Get-ItemProperty -Path $pplKey -Name RunAsPPL -ErrorAction SilentlyContinue).RunAsPPL
$pplEnabled = ($pplVal -eq 1)

# --- LSASS process info ---
$lsass = Get-Process lsass -ErrorAction SilentlyContinue
$lsassInfo = if ($lsass) {
    [PSCustomObject]@{
        pid          = $lsass.Id
        start_time   = $lsass.StartTime.ToUniversalTime().ToString('o')
        working_set  = $lsass.WorkingSet64
        handle_count = $lsass.HandleCount
    }
} else { $null }

# --- Object access events (EID 4656/4663 vs lsass) ---
$accessEvents = @()
try {
    $evts = Get-WinEvent -LogName Security -ErrorAction Stop |
            Where-Object { $_.Id -in @(4656,4663) -and $_.TimeCreated -ge $cutoff }
    $accessEvents = $evts | Where-Object {
        $p = $_.Properties
        ($p | Where-Object { $_.Value -like '*lsass*' }).Count -gt 0
    } | ForEach-Object {
        $p = $_.Properties
        [PSCustomObject]@{
            timestamp    = $_.TimeCreated.ToUniversalTime().ToString('o')
            event_id     = $_.Id
            subject_user = if ($p.Count -gt 1) { [string]$p[1].Value } else { '' }
            object_name  = if ($p.Count -gt 6) { [string]$p[6].Value } else { '' }
            access_mask  = if ($p.Count -gt 9) { '0x{0:X}' -f $p[9].Value } else { '' }
            process_name = if ($p.Count -gt 11){ [string]$p[11].Value } else { '' }
            process_id   = if ($p.Count -gt 12){ [string]$p[12].Value } else { '' }
        }
    }
} catch {}

# --- Known LSASS-dumping tool names ---
$dumpTools = @('mimikatz','procdump','sqldumper','nanodump','lsassy',
               'pypykatz','wce','pwdump','fgdump','gsecdump','cachedump',
               'comsvcs','comsvc','werfault','rundll32')

$suspProcs = Get-Process -ErrorAction SilentlyContinue | Where-Object {
    $name = $_.Name.ToLower()
    $dumpTools -contains $name -or ($name -like '*dump*' -and $name -notlike 'crashdump*')
} | ForEach-Object {
    [PSCustomObject]@{
        name       = $_.Name
        pid        = $_.Id
        path       = try { $_.MainModule.FileName } catch { '<access denied>' }
        start_time = try { $_.StartTime.ToUniversalTime().ToString('o') } catch { '' }
    }
}

[PSCustomObject]@{
    ppl_enabled          = $pplEnabled
    ppl_registry_value   = $pplVal
    lsass_process        = $lsassInfo
    access_events_count  = @($accessEvents).Count
    access_events        = @($accessEvents)
    suspicious_processes = @($suspProcs)
} | ConvertTo-Json -Depth 4 -Compress
""" % hours
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #6 – Shadow Copy / VSS Status
    # -----------------------------------------------------------------------
    def _endpoint_shadow_copy_status(self, params, timeout):
        """Enumerate Volume Shadow Copies and VSS service state.

        Absence of shadow copies on a production server is a high-confidence
        ransomware pre-cursor indicator.  This action also checks the VSS
        service startup type so you can distinguish "never configured" from
        "deleted by attacker."
        """
        del params
        self._block_if_not_windows("shadow_copy_status")

        ps = r"""
$vssSvc = Get-Service -Name VSS -ErrorAction SilentlyContinue
$vssInfo = if ($vssSvc) {
    [PSCustomObject]@{
        status       = $vssSvc.Status.ToString()
        start_type   = $vssSvc.StartType.ToString()
    }
} else { [PSCustomObject]@{ status='not_found'; start_type='unknown' } }

$copies = Get-CimInstance Win32_ShadowCopy -ErrorAction SilentlyContinue |
    Sort-Object InstallDate -Descending |
    ForEach-Object {
        [PSCustomObject]@{
            id              = $_.ID
            volume_name     = $_.VolumeName
            device_object   = $_.DeviceObject
            origin_machine  = $_.OriginatingMachine
            service_machine = $_.ServiceMachine
            install_date    = $_.InstallDate.ToUniversalTime().ToString('o')
            set_id          = $_.SetID
            client_accessible = $_.ClientAccessible
            persistent      = $_.Persistent
            state           = $_.State
        }
    }

$volumes = Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveType -eq 'Fixed' }
$volSummary = $volumes | ForEach-Object {
    $driveLetter = $_.DriveLetter
    $snapCount   = @($copies | Where-Object { $_.volume_name -like "*$driveLetter*" }).Count
    [PSCustomObject]@{
        drive        = $driveLetter
        label        = $_.FileSystemLabel
        size_gb      = [math]::Round($_.Size/1GB, 2)
        free_gb      = [math]::Round($_.SizeRemaining/1GB, 2)
        snapshot_count = $snapCount
    }
}

[PSCustomObject]@{
    vss_service        = $vssInfo
    total_shadow_copies = @($copies).Count
    oldest_snapshot    = if (@($copies).Count) { (@($copies))[-1].install_date } else { $null }
    newest_snapshot    = if (@($copies).Count) { (@($copies))[0].install_date  } else { $null }
    volumes            = @($volSummary)
    shadow_copies      = @($copies)
} | ConvertTo-Json -Depth 4 -Compress
"""
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #7 – Named Pipe Enumeration
    # -----------------------------------------------------------------------
    def _endpoint_named_pipe_enumeration(self, params, timeout):
        """List all named pipes and flag those matching C2 framework patterns.

        Detects Cobalt Strike, Metasploit, PsExec, and generic random-name
        pipes that are commonly used for inter-process staging and SMB-based
        C2 communication.
        """
        del params
        self._block_if_not_windows("named_pipe_enumeration")

        ps = r"""
# Known C2 / lateral movement pipe patterns
$c2Patterns = @(
    # Cobalt Strike defaults / post-ex
    'MSSE-\w+-server', 'postex_\w+', 'postex_ssh_\w+', 'status_\w+',
    'msagent_\w+', 'mojo\.\d+\.\d+', 'wkssvc\d+',
    # Metasploit
    'meterpreter\w*', 'MSF\w+',
    # PsExec / PAExec / RemCom
    'PSEXECSVC\w*', 'paexec\w*', 'remcom_\w+',
    # Generic: 8-20 hex-char random names (heuristic)
    '^[0-9a-fA-F]{8,20}$',
    # Generic dump tools
    'lsarpc_\w+', 'samr_\w+'
)

$pipes = Get-ChildItem \\.\pipe\ -ErrorAction SilentlyContinue

# Try to correlate owning PID via NtQueryObject (best-effort)
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class PipeInfo {
    [DllImport("ntdll.dll")] public static extern int NtQueryObject(
        IntPtr Handle, int ObjectInformationClass,
        IntPtr ObjectInformation, int ObjectInformationLength,
        out int ReturnLength);
}
'@ -ErrorAction SilentlyContinue

$procsByPipe = @{}
try {
    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
        $proc = $_
        try {
            $handles = $proc.Modules  # lightweight touch
        } catch {}
    }
} catch {}

$results = $pipes | ForEach-Object {
    $name = $_.Name
    $flag = $false
    $flagReason = ''
    foreach ($pat in $c2Patterns) {
        if ($name -match $pat) {
            $flag = $true
            $flagReason = "matches_pattern:$pat"
            break
        }
    }
    [PSCustomObject]@{
        pipe_name    = $name
        full_path    = $_.FullName
        suspicious   = $flag
        flag_reason  = $flagReason
    }
}

$suspicious = @($results | Where-Object { $_.suspicious })

[PSCustomObject]@{
    total_pipes      = @($results).Count
    suspicious_count = $suspicious.Count
    pipes            = @($results | Sort-Object suspicious -Descending)
} | ConvertTo-Json -Depth 3 -Compress
"""
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #8 – Alternate Data Stream Scanner
    # -----------------------------------------------------------------------
    def _endpoint_ads_scanner(self, params, timeout):
        """Scan a directory for NTFS Alternate Data Streams beyond Zone.Identifier.

        Parameters
        ----------
        path      : str   Directory to scan (required).
        recursive : bool  Recurse into subdirectories (default false).
        include_zone_id : bool  Include legitimate Zone.Identifier streams (default false).
        """
        self._block_if_not_windows("ads_scanner")
        path = params.get("path")
        if not path or not isinstance(path, str) or not path.strip():
            raise PermissionError("Missing or invalid 'path' parameter.")
        path = path.strip()
        if "\x00" in path or len(path) > 512:
            raise PermissionError("Invalid path value.")

        try:
            recursive = str(params.get("recursive", "false")).lower() in ("1", "true", "yes")
        except Exception:
            recursive = False
        try:
            include_zone = str(params.get("include_zone_id", "false")).lower() in ("1", "true", "yes")
        except Exception:
            include_zone = False

        ps = r"""
$scanPath      = '%s'
$recurse       = $%s
$includeZoneId = $%s

if (-not (Test-Path $scanPath)) {
    Write-Output ('{"error":"Path not found: ' + $scanPath + '","results":[]}')
    exit 0
}

$getParams = @{ Path = $scanPath; Stream = '*'; ErrorAction = 'SilentlyContinue' }
if ($recurse) { $getParams['Recurse'] = $true }

$allStreams = Get-Item @getParams

$results = $allStreams | Where-Object {
    $streamName = $_.Stream
    $streamName -ne ':$DATA' -and
    $streamName -ne 'Zone.Identifier' -or $includeZoneId
} | ForEach-Object {
    $content = ''
    if ($_.Length -lt 4096) {
        try {
            $content = Get-Content -Path $_.PSPath -Stream $_.Stream -Raw -ErrorAction SilentlyContinue
            if ($null -eq $content) { $content = '' }
        } catch {}
    }
    [PSCustomObject]@{
        file_path    = $_.PSChildName
        stream_name  = $_.Stream
        stream_size  = $_.Length
        content_preview = if ($content.Length -gt 512) { $content.Substring(0,512)+'...' } else { $content }
    }
}

[PSCustomObject]@{
    scan_path        = $scanPath
    recursive        = $recurse
    ads_found        = @($results).Count
    results          = @($results)
} | ConvertTo-Json -Depth 3 -Compress
""" % (
            path.replace("'", "''"),
            "true" if recursive else "false",
            "true" if include_zone else "false",
        )
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #9 – Amcache / Shimcache
    # -----------------------------------------------------------------------
    def _endpoint_amcache_shimcache(self, params, timeout):
        """Parse Shimcache (AppCompatCache) and attempt Amcache extraction.

        Shimcache is read directly from the live registry binary value and
        parsed natively in Python.  Amcache.hve is copied to a temp location
        using shared-access file I/O (as the service runs as SYSTEM) then
        loaded as a temporary registry hive, read, and immediately unloaded.

        Parameters
        ----------
        source : str   'shimcache', 'amcache', or 'both' (default 'both').
        limit  : int   Max entries per source (default 2000).
        """
        self._block_if_not_windows("amcache_shimcache")

        source = str(params.get("source", "both")).lower()
        if source not in ("shimcache", "amcache", "both"):
            source = "both"
        try:
            limit = max(1, min(10000, int(params.get("limit", 2000))))
        except Exception:
            limit = 2000

        import struct
        import ctypes
        import ctypes.wintypes
        import winreg
        import tempfile
        import datetime as _dt

        def _ft_to_iso(val):
            if val <= 0:
                return None
            try:
                us = (val - 116444736000000000) // 10
                dt = _dt.datetime(1970, 1, 1) + _dt.timedelta(microseconds=us)
                return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
            except Exception:
                return None

        # ------------------------------------------------------------------
        # Shimcache
        # ------------------------------------------------------------------
        shimcache_entries = []
        shimcache_error = None

        if source in ("shimcache", "both"):
            try:
                key = winreg.OpenKey(
                    winreg.HKEY_LOCAL_MACHINE,
                    r"SYSTEM\CurrentControlSet\Control\Session Manager\AppCompatCache",
                    0, winreg.KEY_READ,
                )
                data, _ = winreg.QueryValueEx(key, "AppCompatCache")
                winreg.CloseKey(key)
                raw = bytes(data)

                sig = raw[:4]
                # Win10 / Win2016: "10ts" = 0x73743031
                if sig == b"\x31\x30\x74\x73":
                    # Entries start at offset 52
                    off = 52
                    while off < len(raw) and len(shimcache_entries) < limit:
                        if off + 12 > len(raw):
                            break
                        entry_size = struct.unpack_from("<I", raw, off)[0]
                        if entry_size == 0 or off + entry_size > len(raw):
                            break
                        path_len = struct.unpack_from("<H", raw, off + 8)[0]
                        if path_len == 0 or off + 10 + path_len > len(raw):
                            off += entry_size
                            continue
                        path = raw[off + 10: off + 10 + path_len].decode("utf-16-le", errors="replace")
                        # Last modified time is 8 bytes right after path
                        ts_off = off + 10 + path_len
                        ts = None
                        if ts_off + 8 <= len(raw):
                            ts = _ft_to_iso(struct.unpack_from("<q", raw, ts_off)[0])
                        shimcache_entries.append({"path": path, "last_modified": ts})
                        off += entry_size

                # Win7 / Win2008R2: 0xDEADBEEF
                elif sig == b"\xEF\xBE\xAD\xDE":
                    count = struct.unpack_from("<I", raw, 4)[0]
                    off = 128  # header
                    for _ in range(min(count, limit)):
                        if off + 12 > len(raw):
                            break
                        path_len = struct.unpack_from("<H", raw, off)[0]
                        path_off = off + 8
                        if path_off + path_len > len(raw):
                            break
                        path = raw[path_off: path_off + path_len].decode("utf-16-le", errors="replace")
                        ts_off = off + 8 + path_len + 8
                        ts = None
                        if ts_off + 8 <= len(raw):
                            ts = _ft_to_iso(struct.unpack_from("<q", raw, ts_off)[0])
                        shimcache_entries.append({"path": path, "last_modified": ts})
                        off += 8 + path_len + 72
                else:
                    shimcache_error = "Unknown AppCompatCache signature: %s" % sig.hex()

            except Exception as exc:
                shimcache_error = str(exc)

        # ------------------------------------------------------------------
        # Amcache – copy hive via FILE_SHARE_READ|WRITE then LoadKey
        # ------------------------------------------------------------------
        amcache_entries = []
        amcache_error = None

        if source in ("amcache", "both"):
            AMCACHE_HVE = r"C:\Windows\AppCompat\Programs\Amcache.hve"
            tmp_hve = None
            hive_loaded = False
            TEMP_KEY = "HAWK_TMP_AMCACHE_%d" % os.getpid()

            try:
                # Copy file with shared access using CreateFileW
                kernel32 = ctypes.windll.kernel32
                GENERIC_READ = 0x80000000
                FILE_SHARE_READ = 0x01
                FILE_SHARE_WRITE = 0x02
                OPEN_EXISTING = 3
                FILE_ATTRIBUTE_NORMAL = 0x80

                h = kernel32.CreateFileW(
                    AMCACHE_HVE,
                    GENERIC_READ,
                    FILE_SHARE_READ | FILE_SHARE_WRITE,
                    None,
                    OPEN_EXISTING,
                    FILE_ATTRIBUTE_NORMAL,
                    None,
                )
                INVALID_HANDLE = ctypes.c_void_p(-1).value
                if h == INVALID_HANDLE or h == 0:
                    raise OSError("CreateFileW failed, error %d" % ctypes.get_last_error())

                tmp_hve = tempfile.mktemp(suffix=".hve", prefix="hawk_amc_")
                BUFFER_SIZE = 65536
                with open(tmp_hve, "wb") as dst:
                    buf = ctypes.create_string_buffer(BUFFER_SIZE)
                    bytes_read = ctypes.wintypes.DWORD(0)
                    while True:
                        ok = kernel32.ReadFile(h, buf, BUFFER_SIZE, ctypes.byref(bytes_read), None)
                        if not ok or bytes_read.value == 0:
                            break
                        dst.write(buf.raw[: bytes_read.value])
                kernel32.CloseHandle(h)

                # Load the copied hive under HKLM\TEMP_KEY
                advapi32 = ctypes.windll.advapi32
                ret = advapi32.RegLoadKeyW(
                    ctypes.wintypes.HKEY(0x80000002),  # HKEY_LOCAL_MACHINE
                    TEMP_KEY,
                    tmp_hve,
                )
                if ret != 0:
                    raise OSError("RegLoadKey failed, error %d" % ret)
                hive_loaded = True

                # Read InventoryApplicationFile entries
                try:
                    base = winreg.OpenKey(
                        winreg.HKEY_LOCAL_MACHINE,
                        r"%s\Root\InventoryApplicationFile" % TEMP_KEY,
                        0, winreg.KEY_READ,
                    )
                    idx = 0
                    while len(amcache_entries) < limit:
                        try:
                            sub_name = winreg.EnumKey(base, idx)
                            idx += 1
                        except OSError:
                            break
                        try:
                            sub = winreg.OpenKey(base, sub_name, 0, winreg.KEY_READ)
                            entry = {"key": sub_name}
                            for field in ("LowerCaseLongPath", "FileId", "ProductName",
                                          "Publisher", "Version", "BinProductVersion",
                                          "LinkDate", "ProgramId"):
                                try:
                                    entry[field], _ = winreg.QueryValueEx(sub, field)
                                except Exception:
                                    pass
                            winreg.CloseKey(sub)
                            amcache_entries.append(entry)
                        except Exception:
                            pass
                    winreg.CloseKey(base)
                except Exception as exc:
                    amcache_error = "InventoryApplicationFile read error: %s" % exc

            except Exception as exc:
                amcache_error = str(exc)
            finally:
                if hive_loaded:
                    try:
                        ctypes.windll.advapi32.RegUnLoadKeyW(
                            ctypes.wintypes.HKEY(0x80000002), TEMP_KEY
                        )
                    except Exception:
                        pass
                if tmp_hve and os.path.exists(tmp_hve):
                    try:
                        os.unlink(tmp_hve)
                    except Exception:
                        pass

        result = {
            "source": source,
            "shimcache": {
                "count": len(shimcache_entries),
                "error": shimcache_error,
                "entries": shimcache_entries,
            },
            "amcache": {
                "count": len(amcache_entries),
                "error": amcache_error,
                "entries": amcache_entries,
            },
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "amcache_shimcache", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #10 – Browser History
    # -----------------------------------------------------------------------
    def _endpoint_browser_history(self, params, timeout):
        """Extract browser history from Chrome, Edge, and Firefox on this host.

        Locates each browser's SQLite history database, copies it to a temp
        file to avoid lock conflicts, and queries the visits table filtering
        by the requested time window.

        Parameters
        ----------
        hours   : int   Look-back window in hours (1-720, default 24).
        browser : str   'chrome', 'edge', 'firefox', or 'all' (default 'all').
        """
        self._block_if_not_windows("browser_history")
        try:
            hours = max(1, min(720, int(params.get("hours", 24))))
        except Exception:
            hours = 24
        browser = str(params.get("browser", "all")).lower()
        if browser not in ("chrome", "edge", "firefox", "all"):
            browser = "all"

        import sqlite3
        import shutil
        import glob as _glob
        import tempfile
        import datetime as _dt

        results = {}
        errors = []

        # Chromium epoch: seconds from 1601-01-01.  Convert to Unix epoch.
        CHROMIUM_EPOCH_DELTA = 11644473600  # seconds

        def _chromium_ts_to_iso(us_since_1601):
            try:
                unix_s = (us_since_1601 / 1_000_000) - CHROMIUM_EPOCH_DELTA
                return _dt.datetime.utcfromtimestamp(unix_s).strftime("%Y-%m-%dT%H:%M:%SZ")
            except Exception:
                return None

        cutoff_unix = (_dt.datetime.utcnow() - _dt.timedelta(hours=hours)).timestamp()
        cutoff_chromium_us = int((cutoff_unix + CHROMIUM_EPOCH_DELTA) * 1_000_000)

        # Collect all user profile directories
        users_dir = os.environ.get("SYSTEMDRIVE", "C:") + "\\Users"
        user_dirs = []
        try:
            user_dirs = [
                os.path.join(users_dir, d)
                for d in os.listdir(users_dir)
                if os.path.isdir(os.path.join(users_dir, d))
                and d not in ("Public", "Default", "Default User", "All Users")
            ]
        except Exception:
            pass

        def _query_chromium(db_path, browser_name):
            tmp = None
            try:
                tmp = tempfile.mktemp(suffix=".db", prefix="hawk_hist_")
                shutil.copy2(db_path, tmp)
                conn = sqlite3.connect(tmp)
                conn.row_factory = sqlite3.Row
                cur = conn.cursor()
                cur.execute(
                    "SELECT url, title, visit_count, last_visit_time "
                    "FROM urls WHERE last_visit_time >= ? "
                    "ORDER BY last_visit_time DESC LIMIT 5000",
                    (cutoff_chromium_us,),
                )
                rows = []
                for row in cur.fetchall():
                    rows.append({
                        "url": row["url"],
                        "title": row["title"],
                        "visit_count": row["visit_count"],
                        "last_visit": _chromium_ts_to_iso(row["last_visit_time"]),
                    })
                conn.close()
                return rows
            except Exception as exc:
                errors.append("%s db error (%s): %s" % (browser_name, db_path, exc))
                return []
            finally:
                if tmp and os.path.exists(tmp):
                    try:
                        os.unlink(tmp)
                    except Exception:
                        pass

        def _query_firefox(db_path):
            tmp = None
            try:
                tmp = tempfile.mktemp(suffix=".db", prefix="hawk_ffhist_")
                shutil.copy2(db_path, tmp)
                conn = sqlite3.connect(tmp)
                conn.row_factory = sqlite3.Row
                cur = conn.cursor()
                # Firefox stores last_visit_date in microseconds since Unix epoch
                cutoff_ff = int(cutoff_unix * 1_000_000)
                cur.execute(
                    "SELECT p.url, p.title, p.visit_count, p.last_visit_date "
                    "FROM moz_places p "
                    "WHERE p.last_visit_date >= ? "
                    "ORDER BY p.last_visit_date DESC LIMIT 5000",
                    (cutoff_ff,),
                )
                rows = []
                for row in cur.fetchall():
                    try:
                        ts = _dt.datetime.utcfromtimestamp(row["last_visit_date"] / 1_000_000).strftime("%Y-%m-%dT%H:%M:%SZ")
                    except Exception:
                        ts = None
                    rows.append({
                        "url": row["url"],
                        "title": row["title"],
                        "visit_count": row["visit_count"],
                        "last_visit": ts,
                    })
                conn.close()
                return rows
            except Exception as exc:
                errors.append("firefox db error (%s): %s" % (db_path, exc))
                return []
            finally:
                if tmp and os.path.exists(tmp):
                    try:
                        os.unlink(tmp)
                    except Exception:
                        pass

        for user_dir in user_dirs:
            local_app = os.path.join(user_dir, "AppData", "Local")
            roaming = os.path.join(user_dir, "AppData", "Roaming")
            username = os.path.basename(user_dir)

            if browser in ("chrome", "all"):
                chrome_pattern = os.path.join(local_app, "Google", "Chrome", "User Data", "*", "History")
                for db in _glob.glob(chrome_pattern):
                    profile = os.path.basename(os.path.dirname(db))
                    key = "chrome_%s_%s" % (username, profile)
                    results[key] = _query_chromium(db, key)

            if browser in ("edge", "all"):
                edge_pattern = os.path.join(local_app, "Microsoft", "Edge", "User Data", "*", "History")
                for db in _glob.glob(edge_pattern):
                    profile = os.path.basename(os.path.dirname(db))
                    key = "edge_%s_%s" % (username, profile)
                    results[key] = _query_chromium(db, key)

            if browser in ("firefox", "all"):
                ff_pattern = os.path.join(roaming, "Mozilla", "Firefox", "Profiles", "*", "places.sqlite")
                for db in _glob.glob(ff_pattern):
                    profile = os.path.basename(os.path.dirname(db))
                    key = "firefox_%s_%s" % (username, profile)
                    results[key] = _query_firefox(db)

        total_urls = sum(len(v) for v in results.values())
        out = json.dumps({
            "hours_back": hours, "browser_filter": browser,
            "total_unique_urls": total_urls,
            "profiles_found": len(results),
            "errors": errors,
            "history": results,
        }, indent=2, default=str)
        return ({"command": "browser_history", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #11 – Process Hook / Hidden Code Scanner
    # -----------------------------------------------------------------------
    def _endpoint_process_hook_scan(self, params, timeout):
        """Detect hooked functions and injected code in a target process.

        For each loaded module the scanner reads the first 16 bytes of every
        exported function from disk and compares them to the live in-memory
        bytes.  A mismatch that begins with an unconditional JMP (0xE9, 0xFF25,
        0xEB) or CALL (0xE8) instruction is flagged as a userland inline hook.

        Additionally, every memory region in the process is enumerated via
        VirtualQueryEx.  Regions that are MEM_PRIVATE, PAGE_EXECUTE_READWRITE,
        and not backed by any module are flagged as suspicious shellcode/injected
        code stubs.

        Parameters
        ----------
        pid : int   Target process ID (required).
        """
        self._block_if_not_windows("process_hook_scan")
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)

        import ctypes
        import ctypes.wintypes
        import struct

        kernel32 = ctypes.windll.kernel32
        PROCESS_VM_READ = 0x0010
        PROCESS_QUERY_INFORMATION = 0x0400
        SYNCHRONIZE = 0x00100000

        h_proc = kernel32.OpenProcess(
            PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
            False,
            pid,
        )
        if not h_proc:
            err = ctypes.get_last_error()
            out = json.dumps({"error": "OpenProcess failed (error %d)" % err, "pid": pid})
            return ({"command": "process_hook_scan", "stdout": "", "stderr": out, "exit_code": err}, out)

        hooks = []
        suspicious_regions = []
        module_names = set()

        try:
            # ------------------------------------------------------------------
            # Enumerate loaded modules
            # ------------------------------------------------------------------
            import ctypes.wintypes
            psapi = ctypes.windll.psapi
            MODULES_MAX = 1024
            h_modules = (ctypes.wintypes.HMODULE * MODULES_MAX)()
            cb_needed = ctypes.wintypes.DWORD(0)
            psapi.EnumProcessModulesEx(
                h_proc,
                ctypes.byref(h_modules),
                ctypes.sizeof(h_modules),
                ctypes.byref(cb_needed),
                0x03,  # LIST_MODULES_ALL
            )
            module_count = cb_needed.value // ctypes.sizeof(ctypes.wintypes.HMODULE)

            for i in range(min(module_count, MODULES_MAX)):
                hmod = h_modules[i]
                name_buf = ctypes.create_unicode_buffer(512)
                psapi.GetModuleFileNameExW(h_proc, hmod, name_buf, 512)
                mod_path = name_buf.value
                if not mod_path:
                    continue
                module_names.add(mod_path.lower())

                # Read export table from disk and compare to live memory
                try:
                    with open(mod_path, "rb") as f:
                        disk_data = f.read()
                except Exception:
                    continue

                # Parse PE export directory to get exported function RVAs
                try:
                    dos_sig = disk_data[:2]
                    if dos_sig != b"MZ":
                        continue
                    e_lfanew = struct.unpack_from("<I", disk_data, 0x3C)[0]
                    pe_off = e_lfanew
                    if disk_data[pe_off: pe_off + 4] != b"PE\x00\x00":
                        continue
                    # Optional header starts at pe_off + 24
                    magic = struct.unpack_from("<H", disk_data, pe_off + 24)[0]
                    is_pe64 = (magic == 0x20B)
                    exp_dir_rva_off = pe_off + 24 + (112 if is_pe64 else 96)
                    exp_rva = struct.unpack_from("<I", disk_data, exp_dir_rva_off)[0]
                    if exp_rva == 0:
                        continue

                    def rva_to_off(rva, data=disk_data, pe=pe_off):
                        """Convert RVA to file offset by walking section headers."""
                        num_sections = struct.unpack_from("<H", data, pe + 6)[0]
                        opt_hdr_size = struct.unpack_from("<H", data, pe + 20)[0]
                        sect_off = pe + 24 + opt_hdr_size
                        for s in range(num_sections):
                            so = sect_off + s * 40
                            virt_addr = struct.unpack_from("<I", data, so + 12)[0]
                            virt_size = struct.unpack_from("<I", data, so + 16)[0]
                            raw_off   = struct.unpack_from("<I", data, so + 20)[0]
                            if virt_addr <= rva < virt_addr + virt_size:
                                return raw_off + (rva - virt_addr)
                        return None

                    exp_off = rva_to_off(exp_rva)
                    if exp_off is None or exp_off + 40 > len(disk_data):
                        continue

                    num_funcs  = struct.unpack_from("<I", disk_data, exp_off + 20)[0]
                    num_names  = struct.unpack_from("<I", disk_data, exp_off + 24)[0]
                    funcs_rva  = struct.unpack_from("<I", disk_data, exp_off + 28)[0]
                    names_rva  = struct.unpack_from("<I", disk_data, exp_off + 32)[0]
                    ords_rva   = struct.unpack_from("<I", disk_data, exp_off + 36)[0]

                    names_off  = rva_to_off(names_rva)
                    ords_off   = rva_to_off(ords_rva)
                    funcs_off  = rva_to_off(funcs_rva)
                    if None in (names_off, ords_off, funcs_off):
                        continue

                    for n in range(min(num_names, 2000)):
                        name_rva_off = names_off + n * 4
                        if name_rva_off + 4 > len(disk_data):
                            break
                        fn_name_rva = struct.unpack_from("<I", disk_data, name_rva_off)[0]
                        fn_name_off = rva_to_off(fn_name_rva)
                        if fn_name_off is None:
                            continue
                        end = disk_data.find(b"\x00", fn_name_off)
                        fn_name = disk_data[fn_name_off: end].decode("ascii", errors="replace") if end != -1 else ""

                        ord_off = ords_off + n * 2
                        if ord_off + 2 > len(disk_data):
                            break
                        ordinal = struct.unpack_from("<H", disk_data, ord_off)[0]
                        func_rva_off = funcs_off + ordinal * 4
                        if func_rva_off + 4 > len(disk_data):
                            continue
                        fn_rva = struct.unpack_from("<I", disk_data, func_rva_off)[0]
                        if fn_rva == 0:
                            continue
                        fn_file_off = rva_to_off(fn_rva)
                        if fn_file_off is None or fn_file_off + 16 > len(disk_data):
                            continue

                        disk_bytes = disk_data[fn_file_off: fn_file_off + 16]

                        # Read live bytes from process memory
                        fn_va = hmod + fn_rva
                        mem_buf = ctypes.create_string_buffer(16)
                        bytes_read = ctypes.c_size_t(0)
                        kernel32.ReadProcessMemory(h_proc, ctypes.c_void_p(fn_va), mem_buf, 16, ctypes.byref(bytes_read))
                        if bytes_read.value < 5:
                            continue
                        live_bytes = bytes(mem_buf.raw[: bytes_read.value])

                        if live_bytes[:5] == disk_bytes[:5]:
                            continue

                        # Check if live bytes start with a hook prologue
                        hook_type = None
                        if live_bytes[0] == 0xE9:
                            hook_type = "JMP_REL32"
                        elif live_bytes[:2] == b"\xFF\x25":
                            hook_type = "JMP_ABS"
                        elif live_bytes[0] == 0xEB:
                            hook_type = "JMP_SHORT"
                        elif live_bytes[0] == 0xE8:
                            hook_type = "CALL_REL32"
                        elif live_bytes[0] == 0xCC:
                            hook_type = "INT3_BREAKPOINT"

                        if hook_type:
                            hooks.append({
                                "module": os.path.basename(mod_path),
                                "module_path": mod_path,
                                "function": fn_name,
                                "hook_type": hook_type,
                                "live_bytes": live_bytes[:8].hex(),
                                "disk_bytes": disk_bytes[:8].hex(),
                                "function_va": "0x%X" % fn_va,
                            })

                except Exception:
                    continue

            # ------------------------------------------------------------------
            # Enumerate memory regions for suspicious executable stubs
            # ------------------------------------------------------------------
            class MEMORY_BASIC_INFORMATION(ctypes.Structure):
                _fields_ = [
                    ("BaseAddress",       ctypes.c_void_p),
                    ("AllocationBase",    ctypes.c_void_p),
                    ("AllocationProtect", ctypes.wintypes.DWORD),
                    ("RegionSize",        ctypes.c_size_t),
                    ("State",             ctypes.wintypes.DWORD),
                    ("Protect",           ctypes.wintypes.DWORD),
                    ("Type",              ctypes.wintypes.DWORD),
                ]

            MEM_COMMIT  = 0x1000
            MEM_PRIVATE = 0x20000
            PAGE_EXECUTE_READWRITE = 0x40
            PAGE_EXECUTE_WRITECOPY = 0x80
            PAGE_EXECUTE           = 0x10
            PAGE_EXECUTE_READ      = 0x20
            EXECUTABLE_PAGES = {PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY}

            addr = 0
            mbi  = MEMORY_BASIC_INFORMATION()
            while kernel32.VirtualQueryEx(h_proc, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
                if (mbi.State == MEM_COMMIT and
                        mbi.Type == MEM_PRIVATE and
                        mbi.Protect in EXECUTABLE_PAGES and
                        mbi.RegionSize > 0):
                    # Read first 64 bytes for a heuristic signature
                    preview_buf = ctypes.create_string_buffer(64)
                    br = ctypes.c_size_t(0)
                    kernel32.ReadProcessMemory(h_proc, ctypes.c_void_p(mbi.BaseAddress), preview_buf, 64, ctypes.byref(br))
                    preview = bytes(preview_buf.raw[: br.value]).hex() if br.value else ""
                    suspicious_regions.append({
                        "base_address": "0x%X" % (mbi.BaseAddress or 0),
                        "size": mbi.RegionSize,
                        "protect": "0x%X" % mbi.Protect,
                        "preview_hex": preview,
                    })

                next_addr = (mbi.BaseAddress or 0) + mbi.RegionSize
                if next_addr <= addr:
                    break
                addr = next_addr

        finally:
            kernel32.CloseHandle(h_proc)

        result = {
            "pid": pid,
            "hooks_found": len(hooks),
            "suspicious_regions": len(suspicious_regions),
            "hooks": hooks,
            "suspicious_executable_regions": suspicious_regions,
        }
        out = json.dumps(result, indent=2, default=str)
        code = 0 if (not hooks and not suspicious_regions) else 1
        return ({"command": "process_hook_scan", "stdout": out, "stderr": "", "exit_code": code}, out)

    # -----------------------------------------------------------------------
    # IR Tool #12 – WMI Persistence Deep Dive
    # -----------------------------------------------------------------------
    def _endpoint_wmi_persistence_deep(self, params, timeout):
        """Enumerate all WMI event subscriptions across every namespace.

        Pulls __EventFilter (query), __EventConsumer (payload — including full
        CommandLineTemplate and ScriptText), and __FilterToConsumerBinding
        (which filter drives which consumer) from root\\subscription,
        root\\default, and root\\cimv2.  Extra namespaces can be added via
        the ``namespaces`` parameter.

        Parameters
        ----------
        namespaces : str   Comma-separated extra WMI namespaces to scan.
        """
        del params
        self._block_if_not_windows("wmi_persistence_deep")

        extra_ns = str(params.get("namespaces", "") if isinstance(params, dict) else "").strip()
        extra_ns_list = [n.strip() for n in extra_ns.split(",") if n.strip()] if extra_ns else []
        all_ns = ["root\\\\subscription", "root\\\\default", "root\\\\cimv2"] + extra_ns_list

        ps = r"""
$namespaces = @(%s)
$report     = [System.Collections.Generic.List[PSCustomObject]]::new()

foreach ($ns in $namespaces) {
    # --- Filters ---
    $filters = Get-WmiObject -Namespace $ns -Class __EventFilter `
                             -ErrorAction SilentlyContinue
    foreach ($f in $filters) {
        $report.Add([PSCustomObject]@{
            namespace     = $ns
            object_type   = '__EventFilter'
            name          = $f.Name
            query         = $f.Query
            query_language = $f.QueryLanguage
            event_namespace = $f.EventNamespace
            payload       = $null
            scripting_engine = $null
        })
    }

    # --- Consumers ---
    $consumers = Get-WmiObject -Namespace $ns -Class __EventConsumer `
                               -ErrorAction SilentlyContinue
    foreach ($c in $consumers) {
        $payload = ''
        $engine  = ''
        # CommandLineEventConsumer
        if ($c.CommandLineTemplate) { $payload = $c.CommandLineTemplate }
        if ($c.ExecutablePath)      { $payload = $c.ExecutablePath + ' ' + $payload }
        # ActiveScriptEventConsumer
        if ($c.ScriptText)          { $payload = $c.ScriptText }
        if ($c.ScriptingEngine)     { $engine  = $c.ScriptingEngine }
        # LogFileEventConsumer
        if ($c.Text -and -not $payload) { $payload = $c.Text }

        $report.Add([PSCustomObject]@{
            namespace        = $ns
            object_type      = $c.__CLASS
            name             = $c.Name
            query            = $null
            query_language   = $null
            event_namespace  = $null
            payload          = $payload
            scripting_engine = $engine
        })
    }

    # --- Bindings ---
    $bindings = Get-WmiObject -Namespace $ns -Class __FilterToConsumerBinding `
                              -ErrorAction SilentlyContinue
    foreach ($b in $bindings) {
        $report.Add([PSCustomObject]@{
            namespace        = $ns
            object_type      = '__FilterToConsumerBinding'
            name             = "$($b.Filter) -> $($b.Consumer)"
            query            = $null
            query_language   = $null
            event_namespace  = $null
            payload          = $null
            scripting_engine = $null
        })
    }
}

[PSCustomObject]@{
    total_objects    = $report.Count
    subscription_namespaces = $namespaces
    objects          = @($report)
} | ConvertTo-Json -Depth 4 -Compress
""" % (", ".join("'%s'" % n for n in all_ns))
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #13 – ETW Tamper Detection
    # -----------------------------------------------------------------------
    def _endpoint_etw_tamper_detection(self, params, timeout):
        """Detect inline patches to ETW logging functions across all processes.

        Reads ``EtwEventWrite``, ``EtwEventWriteFull``, and
        ``NtTraceEvent`` from ntdll.dll on disk and compares the first 16 bytes
        of each against the live in-memory bytes in every accessible process.
        A prologue replaced with ``xor eax,eax; ret``, ``mov eax,0; ret``,
        or a simple ``ret`` is the canonical EDR-evasion ETW patch and is
        flagged with the hook type, offending bytes, and PID.

        Parameters
        ----------
        pid : int   Limit scan to a single PID (optional; default scans all).
        """
        self._block_if_not_windows("etw_tamper_detection")

        import ctypes
        import ctypes.wintypes
        import struct

        try:
            target_pid = int(params.get("pid", 0))
        except Exception:
            target_pid = 0

        kernel32 = ctypes.windll.kernel32
        psapi    = ctypes.windll.psapi

        PROCESS_VM_READ          = 0x0010
        PROCESS_QUERY_INFORMATION = 0x0400
        TH32CS_SNAPPROCESS        = 0x00000002
        INVALID_HANDLE            = ctypes.c_void_p(-1).value

        # ------------------------------------------------------------------
        # Parse ntdll.dll on disk → get RVAs for the target functions
        # ------------------------------------------------------------------
        ntdll_path = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "ntdll.dll")
        ETW_FUNCS = ["EtwEventWrite", "EtwEventWriteFull", "NtTraceEvent"]

        try:
            disk_data = open(ntdll_path, "rb").read()
        except Exception as exc:
            out = json.dumps({"error": "Cannot read ntdll.dll: %s" % exc})
            return ({"command": "etw_tamper_detection", "stdout": "", "stderr": out, "exit_code": 1}, out)

        def _rva_to_offset(rva, data):
            e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
            num_sect  = struct.unpack_from("<H", data, e_lfanew + 6)[0]
            opt_size  = struct.unpack_from("<H", data, e_lfanew + 20)[0]
            sect_base = e_lfanew + 24 + opt_size
            for i in range(num_sect):
                s = sect_base + i * 40
                va   = struct.unpack_from("<I", data, s + 12)[0]
                vsz  = struct.unpack_from("<I", data, s + 16)[0]
                raw  = struct.unpack_from("<I", data, s + 20)[0]
                if va <= rva < va + vsz:
                    return raw + (rva - va)
            return None

        def _get_export_rvas(data, func_names):
            """Return {func_name: rva} for requested exports."""
            try:
                e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
                magic    = struct.unpack_from("<H", data, e_lfanew + 24)[0]
                is64     = (magic == 0x20B)
                exp_rva  = struct.unpack_from("<I", data, e_lfanew + 24 + (112 if is64 else 96))[0]
                exp_off  = _rva_to_offset(exp_rva, data)
                if exp_off is None:
                    return {}
                n_names   = struct.unpack_from("<I", data, exp_off + 24)[0]
                funcs_rva = struct.unpack_from("<I", data, exp_off + 28)[0]
                names_rva = struct.unpack_from("<I", data, exp_off + 32)[0]
                ords_rva  = struct.unpack_from("<I", data, exp_off + 36)[0]
                names_off = _rva_to_offset(names_rva, data)
                ords_off  = _rva_to_offset(ords_rva, data)
                funcs_off = _rva_to_offset(funcs_rva, data)
                result = {}
                for i in range(n_names):
                    nr_off   = names_off + i * 4
                    fn_rva   = struct.unpack_from("<I", data, nr_off)[0]
                    fn_off   = _rva_to_offset(fn_rva, data)
                    end      = data.find(b"\x00", fn_off)
                    fn_name  = data[fn_off:end].decode("ascii", errors="replace")
                    if fn_name in func_names:
                        ord_val  = struct.unpack_from("<H", data, ords_off + i * 2)[0]
                        func_rva = struct.unpack_from("<I", data, funcs_off + ord_val * 4)[0]
                        result[fn_name] = func_rva
                return result
            except Exception:
                return {}

        disk_rvas   = _get_export_rvas(disk_data, set(ETW_FUNCS))
        disk_bytes  = {}
        for fn, rva in disk_rvas.items():
            off = _rva_to_offset(rva, disk_data)
            if off and off + 16 <= len(disk_data):
                disk_bytes[fn] = disk_data[off: off + 16]

        if not disk_bytes:
            out = json.dumps({"error": "Could not resolve ETW exports from ntdll.dll"})
            return ({"command": "etw_tamper_detection", "stdout": "", "stderr": out, "exit_code": 1}, out)

        # ------------------------------------------------------------------
        # Snapshot running processes
        # ------------------------------------------------------------------
        class PROCESSENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize",              ctypes.wintypes.DWORD),
                ("cntUsage",            ctypes.wintypes.DWORD),
                ("th32ProcessID",       ctypes.wintypes.DWORD),
                ("th32DefaultHeapID",   ctypes.POINTER(ctypes.c_ulong)),
                ("th32ModuleID",        ctypes.wintypes.DWORD),
                ("cntThreads",          ctypes.wintypes.DWORD),
                ("th32ParentProcessID", ctypes.wintypes.DWORD),
                ("pcPriClassBase",      ctypes.c_long),
                ("dwFlags",             ctypes.wintypes.DWORD),
                ("szExeFile",           ctypes.c_char * 260),
            ]

        snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
        pids = []
        if snap and snap != INVALID_HANDLE:
            entry = PROCESSENTRY32()
            entry.dwSize = ctypes.sizeof(PROCESSENTRY32)
            if kernel32.Process32First(snap, ctypes.byref(entry)):
                while True:
                    pids.append((entry.th32ProcessID, entry.szExeFile.decode("ascii", errors="replace")))
                    if not kernel32.Process32Next(snap, ctypes.byref(entry)):
                        break
            kernel32.CloseHandle(snap)

        if target_pid:
            pids = [(p, n) for p, n in pids if p == target_pid]

        # ------------------------------------------------------------------
        # For each process: find ntdll base, compute function VAs, compare
        # ------------------------------------------------------------------
        MAX_MODULES = 512
        tampered    = []
        clean       = []
        errors      = []

        for pid, proc_name in pids:
            if pid == 0:
                continue
            h = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
            if not h:
                continue
            try:
                mods = (ctypes.wintypes.HMODULE * MAX_MODULES)()
                cb   = ctypes.wintypes.DWORD(0)
                psapi.EnumProcessModulesEx(h, ctypes.byref(mods), ctypes.sizeof(mods), ctypes.byref(cb), 0x03)
                count = cb.value // ctypes.sizeof(ctypes.wintypes.HMODULE)
                ntdll_base = None
                for i in range(min(count, MAX_MODULES)):
                    buf = ctypes.create_unicode_buffer(512)
                    psapi.GetModuleFileNameExW(h, mods[i], buf, 512)
                    if "ntdll.dll" in buf.value.lower():
                        ntdll_base = mods[i]
                        break
                if ntdll_base is None:
                    kernel32.CloseHandle(h)
                    continue

                proc_status = {"pid": pid, "process": proc_name, "functions": {}}
                any_tamper = False
                for fn_name, rva in disk_rvas.items():
                    va     = ntdll_base + rva
                    mem    = ctypes.create_string_buffer(16)
                    bread  = ctypes.c_size_t(0)
                    kernel32.ReadProcessMemory(h, ctypes.c_void_p(va), mem, 16, ctypes.byref(bread))
                    if bread.value < 5:
                        continue
                    live  = bytes(mem.raw[: bread.value])
                    clean_b = disk_bytes.get(fn_name, b"")
                    if live[:8] == clean_b[:8]:
                        proc_status["functions"][fn_name] = "clean"
                        continue

                    # Classify the patch type
                    hook = "MODIFIED"
                    # xor eax,eax; ret  (33 C0 C3)
                    if live[:3] == b"\x33\xC0\xC3":
                        hook = "XOR_EAX_RET (ETW nullified)"
                    # mov eax,0; ret  (B8 00 00 00 00 C3)
                    elif live[:6] == b"\xB8\x00\x00\x00\x00\xC3":
                        hook = "MOV_EAX0_RET (ETW nullified)"
                    # ret (C3)
                    elif live[0] == 0xC3:
                        hook = "RET_STUB (ETW nullified)"
                    # jmp rel32
                    elif live[0] == 0xE9:
                        hook = "JMP_REL32 (redirected)"
                    # jmp [rip+x] / jmp qword ptr
                    elif live[:2] == b"\xFF\x25":
                        hook = "JMP_ABS (redirected)"
                    # int3
                    elif live[0] == 0xCC:
                        hook = "INT3_PATCH"

                    proc_status["functions"][fn_name] = {
                        "status": hook,
                        "live_bytes": live[:8].hex(),
                        "disk_bytes": clean_b[:8].hex(),
                        "function_va": "0x%X" % va,
                    }
                    any_tamper = True

                if any_tamper:
                    tampered.append(proc_status)
                else:
                    clean.append({"pid": pid, "process": proc_name})
            except Exception as exc:
                errors.append({"pid": pid, "error": str(exc)})
            finally:
                kernel32.CloseHandle(h)

        result = {
            "functions_checked": list(disk_rvas.keys()),
            "processes_scanned": len(pids),
            "tampered_count": len(tampered),
            "tampered": tampered,
            "clean_count": len(clean),
            "errors": errors,
        }
        out = json.dumps(result, indent=2, default=str)
        code = 1 if tampered else 0
        return ({"command": "etw_tamper_detection", "stdout": out, "stderr": "", "exit_code": code}, out)

    # -----------------------------------------------------------------------
    # IR Tool #14 – Kernel Driver / Rootkit Scan
    # -----------------------------------------------------------------------
    def _endpoint_kernel_driver_scan(self, params, timeout):
        """Enumerate all loaded kernel drivers and flag unsigned or suspicious ones.

        Uses Win32_SystemDriver (WMI) for service metadata and
        Get-AuthenticodeSignature for code-signing status.  Flags drivers that
        are unsigned, loaded from user-writable paths, or present in memory
        (via EnumDeviceDrivers) but absent from the registry service list —
        the key rootkit indicator.
        """
        del params
        self._block_if_not_windows("kernel_driver_scan")

        ps = r"""
# --- Registry driver services ---
$regDrivers = @{}
$svcRoot = 'HKLM:\SYSTEM\CurrentControlSet\Services'
Get-ChildItem $svcRoot -ErrorAction SilentlyContinue | ForEach-Object {
    $props = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
    # Type 1=Kernel, 2=FileSystem, 4=Adapter, 8=Recognizer
    if ($props.Type -in @(1,2,4,8)) {
        $imgPath = $props.ImagePath -replace '\\SystemRoot', $env:SystemRoot `
                                    -replace '^\\\?\?\\', '' `
                                    -replace '^\\Device\\.*', ''
        $regDrivers[$_.PSChildName.ToLower()] = [PSCustomObject]@{
            service_name = $_.PSChildName
            image_path   = $imgPath
            start_type   = $props.Start
            type         = $props.Type
        }
    }
}

# --- WMI driver list (running state) ---
$wmiDrivers = Get-WmiObject Win32_SystemDriver -ErrorAction SilentlyContinue

$results = $wmiDrivers | ForEach-Object {
    $name = $_.Name.ToLower()
    $raw  = $_.PathName
    $path = $raw -replace '"', '' `
                 -replace '\\SystemRoot\\', ($env:SystemRoot + '\') `
                 -replace '^\\\?\?\\', ''

    $inReg = $regDrivers.ContainsKey($name)

    $signed   = 'unknown'
    $signer   = ''
    $sha256   = ''
    if ($path -and (Test-Path $path -ErrorAction SilentlyContinue)) {
        $sig = Get-AuthenticodeSignature -FilePath $path -ErrorAction SilentlyContinue
        if ($sig) {
            $signed = $sig.Status.ToString()
            $signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' }
        }
        try {
            $sha256 = (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash
        } catch {}
    }

    $suspicious  = $false
    $flag_reason = [System.Collections.Generic.List[string]]::new()

    if ($signed -ne 'Valid')                              { $suspicious=$true; $flag_reason.Add('unsigned_or_untrusted') }
    if (-not $inReg)                                      { $suspicious=$true; $flag_reason.Add('not_in_registry_services') }
    if ($path -match '\\Temp\\|\\AppData\\|\\Users\\Public\\|\\Downloads\\') {
                                                            $suspicious=$true; $flag_reason.Add('suspicious_path') }
    if ($path -match '\\ProgramData\\(?!Microsoft)') {    $suspicious=$true; $flag_reason.Add('programdata_non_ms') }

    [PSCustomObject]@{
        name          = $_.Name
        display_name  = $_.DisplayName
        state         = $_.State
        start_mode    = $_.StartMode
        path          = $path
        sha256        = $sha256
        signed        = $signed
        signer        = $signer
        in_registry   = $inReg
        suspicious    = $suspicious
        flag_reason   = $flag_reason -join ';'
    }
}

[PSCustomObject]@{
    total_drivers     = @($results).Count
    suspicious_count  = @($results | Where-Object { $_.suspicious }).Count
    drivers           = @($results | Sort-Object suspicious -Descending)
} | ConvertTo-Json -Depth 3 -Compress
"""
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps.strip()],
            timeout,
        )

    # -----------------------------------------------------------------------
    # IR Tool #15 – Token & Privilege Inspection
    # -----------------------------------------------------------------------
    def _endpoint_token_privilege_inspect(self, params, timeout):
        """Inspect the access token of a process for dangerous privileges.

        Opens the process token and enumerates: all privileges (name, LUID,
        and enabled/disabled state), token integrity level, elevation type,
        token type (primary vs impersonation), and impersonation level.
        High-value privileges (SeDebug, SeImpersonate, SeTcb, SeLoadDriver,
        etc.) are highlighted in a separate ``dangerous_privs`` list.

        Parameters
        ----------
        pid : int   Target PID (required).
        """
        self._block_if_not_windows("token_privilege_inspect")
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)

        import ctypes
        import ctypes.wintypes

        advapi32 = ctypes.windll.advapi32
        kernel32  = ctypes.windll.kernel32

        PROCESS_QUERY_INFORMATION = 0x0400
        TOKEN_QUERY               = 0x0008
        SE_PRIVILEGE_ENABLED      = 0x00000002
        SE_PRIVILEGE_ENABLED_BY_DEFAULT = 0x00000001

        TokenPrivileges    = 3
        TokenGroups        = 2
        TokenType          = 8
        TokenImpersonation = 9
        TokenIntegrityLevel = 25
        TokenElevationType  = 18

        DANGEROUS_PRIVS = {
            "SeDebugPrivilege", "SeImpersonatePrivilege", "SeTcbPrivilege",
            "SeLoadDriverPrivilege", "SeCreateTokenPrivilege",
            "SeAssignPrimaryTokenPrivilege", "SeBackupPrivilege",
            "SeRestorePrivilege", "SeTakeOwnershipPrivilege",
            "SeSecurityPrivilege", "SeAuditPrivilege",
            "SeManageVolumePrivilege", "SeCreateSymbolicLinkPrivilege",
        }

        h_proc = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
        if not h_proc:
            err = ctypes.get_last_error()
            out = json.dumps({"error": "OpenProcess failed (error %d)" % err, "pid": pid})
            return ({"command": "token_privilege_inspect", "stdout": "", "stderr": out, "exit_code": err}, out)

        h_token = ctypes.wintypes.HANDLE()
        if not advapi32.OpenProcessToken(h_proc, TOKEN_QUERY, ctypes.byref(h_token)):
            kernel32.CloseHandle(h_proc)
            err = ctypes.get_last_error()
            out = json.dumps({"error": "OpenProcessToken failed (error %d)" % err, "pid": pid})
            return ({"command": "token_privilege_inspect", "stdout": "", "stderr": out, "exit_code": err}, out)

        def _query_token(info_class, buf_size=256):
            buf  = ctypes.create_string_buffer(buf_size)
            size = ctypes.wintypes.DWORD(0)
            ok   = advapi32.GetTokenInformation(h_token, info_class, buf, buf_size, ctypes.byref(size))
            if not ok and ctypes.get_last_error() == 122:  # ERROR_INSUFFICIENT_BUFFER
                buf  = ctypes.create_string_buffer(size.value)
                ok   = advapi32.GetTokenInformation(h_token, info_class, buf, size.value, ctypes.byref(size))
            return buf.raw if ok else None

        result = {"pid": pid, "privileges": [], "dangerous_privs": [],
                  "integrity_level": None, "token_type": None,
                  "elevation_type": None, "error": None}

        try:
            # --- Privileges ---
            raw = _query_token(TokenPrivileges, 512)
            if raw:
                import struct as _struct
                count = _struct.unpack_from("<I", raw, 0)[0]
                off   = 4
                for _ in range(count):
                    if off + 12 > len(raw):
                        break
                    luid_low, luid_high, attrs = _struct.unpack_from("<IIL", raw, off)
                    off += 12
                    # LookupPrivilegeName
                    class LUID(ctypes.Structure):
                        _fields_ = [("LowPart", ctypes.c_ulong), ("HighPart", ctypes.c_long)]
                    luid_s = LUID(LowPart=luid_low, HighPart=luid_high)
                    name_buf  = ctypes.create_unicode_buffer(256)
                    name_size = ctypes.wintypes.DWORD(256)
                    advapi32.LookupPrivilegeNameW(None, ctypes.byref(luid_s), name_buf, ctypes.byref(name_size))
                    priv_name = name_buf.value
                    enabled   = bool(attrs & SE_PRIVILEGE_ENABLED)
                    priv_entry = {
                        "name":    priv_name,
                        "enabled": enabled,
                        "default": bool(attrs & SE_PRIVILEGE_ENABLED_BY_DEFAULT),
                    }
                    result["privileges"].append(priv_entry)
                    if priv_name in DANGEROUS_PRIVS:
                        result["dangerous_privs"].append({"name": priv_name, "enabled": enabled})

            # --- Integrity level ---
            raw = _query_token(TokenIntegrityLevel, 64)
            if raw:
                import struct as _s
                # SID is at start; the RID (last sub-authority) maps to integrity
                sid_ptr = ctypes.cast(raw, ctypes.c_char_p)
                # Read sub-authority count and last sub-authority
                sa_count = _s.unpack_from("B", raw, 1)[0]
                if sa_count > 0:
                    rid = _s.unpack_from("<I", raw, 8 + (sa_count - 1) * 4)[0]
                    result["integrity_level"] = {
                        0x0000: "Untrusted", 0x1000: "Low", 0x2000: "Medium",
                        0x2100: "MediumPlus", 0x3000: "High", 0x4000: "System",
                    }.get(rid, "0x%X" % rid)

            # --- Token type ---
            raw = _query_token(TokenType, 8)
            if raw:
                t = int.from_bytes(raw[:4], "little")
                result["token_type"] = {1: "Primary", 2: "Impersonation"}.get(t, str(t))

            # --- Elevation type ---
            raw = _query_token(TokenElevationType, 8)
            if raw:
                e = int.from_bytes(raw[:4], "little")
                result["elevation_type"] = {1: "Default", 2: "Full", 3: "Limited"}.get(e, str(e))

        except Exception as exc:
            result["error"] = str(exc)
        finally:
            kernel32.CloseHandle(h_token)
            kernel32.CloseHandle(h_proc)

        out = json.dumps(result, indent=2, default=str)
        code = 1 if result["dangerous_privs"] else 0
        return ({"command": "token_privilege_inspect", "stdout": out, "stderr": "", "exit_code": code}, out)

    # -----------------------------------------------------------------------
    # IR Tool #16 – Memory IOC / String Extractor
    # -----------------------------------------------------------------------
    def _endpoint_memory_ioc_extractor(self, params, timeout):
        """Extract IOC strings from a live process's readable memory regions.

        Scans all committed, readable memory regions of the target process via
        VirtualQueryEx + ReadProcessMemory and applies regex patterns for IPv4,
        IPv6, URLs, domains, Windows paths, UNC paths, named pipes, and
        suspicious base64 blobs.  Results are deduplicated and grouped by type.

        Parameters
        ----------
        pid       : int   Target process ID (required).
        min_b64   : int   Minimum Base64 blob length to capture (default 64).
        """
        self._block_if_not_windows("memory_ioc_extractor")
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)
        try:
            min_b64 = max(32, min(512, int(params.get("min_b64", 64))))
        except Exception:
            min_b64 = 64

        import ctypes
        import ctypes.wintypes
        import re as _re

        PROCESS_VM_READ           = 0x0010
        PROCESS_QUERY_INFORMATION = 0x0400
        MEM_COMMIT   = 0x1000
        PAGE_NOACCESS = 0x01
        PAGE_GUARD    = 0x100

        kernel32 = ctypes.windll.kernel32

        h = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
        if not h:
            err = ctypes.get_last_error()
            out = json.dumps({"error": "OpenProcess failed (error %d)" % err, "pid": pid})
            return ({"command": "memory_ioc_extractor", "stdout": "", "stderr": out, "exit_code": err}, out)

        # Patterns (compiled once)
        PAT_IPV4   = _re.compile(rb'\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b')
        PAT_URL    = _re.compile(rb'https?://[A-Za-z0-9\-._~:/?#\[\]@!$&\'()*+,;=%]{8,300}')
        PAT_DOMAIN = _re.compile(rb'\b[A-Za-z0-9\-]{2,63}\.(?:com|net|org|io|gov|mil|onion|xyz|biz|info|ru|cn|de|uk|co)\b')
        PAT_B64    = _re.compile(rb'[A-Za-z0-9+/]{%d,}(?:={0,2})' % min_b64)
        PAT_PATH   = _re.compile(rb'[A-Za-z]:\\(?:[^\x00\n\r"<>|?*\\]{1,255}\\){0,20}[^\x00\n\r"<>|?*\\]{1,255}')
        PAT_UNC    = _re.compile(rb'\\\\[A-Za-z0-9\-\.]{2,63}\\[^\x00\n\r]{2,100}')
        PAT_PIPE   = _re.compile(rb'\\\\\.\\pipe\\[^\x00\n\r\\]{2,100}')
        PAT_REGISTRY = _re.compile(rb'(?:HKEY_LOCAL_MACHINE|HKLM|HKEY_CURRENT_USER|HKCU)\\[^\x00\n\r"]{4,200}')

        iocs = {k: set() for k in ("ipv4", "url", "domain", "base64", "path", "unc", "pipe", "registry")}
        PRIVATE_RANGES = [b"127.", b"10.", b"192.168.", b"172.16.", b"169.254."]

        class MEMORY_BASIC_INFORMATION(ctypes.Structure):
            _fields_ = [
                ("BaseAddress",       ctypes.c_void_p),
                ("AllocationBase",    ctypes.c_void_p),
                ("AllocationProtect", ctypes.wintypes.DWORD),
                ("RegionSize",        ctypes.c_size_t),
                ("State",             ctypes.wintypes.DWORD),
                ("Protect",           ctypes.wintypes.DWORD),
                ("Type",              ctypes.wintypes.DWORD),
            ]

        addr     = 0
        mbi      = MEMORY_BASIC_INFORMATION()
        CHUNK    = 4 * 1024 * 1024  # read 4 MB at a time

        try:
            while kernel32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
                size     = mbi.RegionSize
                protect  = mbi.Protect
                readable = (
                    mbi.State == MEM_COMMIT
                    and not (protect & PAGE_NOACCESS)
                    and not (protect & PAGE_GUARD)
                )
                if readable and size > 0:
                    read_off = 0
                    while read_off < size:
                        chunk_sz = min(CHUNK, size - read_off)
                        buf      = ctypes.create_string_buffer(chunk_sz)
                        bread    = ctypes.c_size_t(0)
                        kernel32.ReadProcessMemory(
                            h, ctypes.c_void_p((mbi.BaseAddress or 0) + read_off),
                            buf, chunk_sz, ctypes.byref(bread),
                        )
                        if bread.value:
                            data = bytes(buf.raw[: bread.value])
                            for m in PAT_IPV4.finditer(data):
                                v = m.group()
                                if not any(v.startswith(p) for p in PRIVATE_RANGES):
                                    iocs["ipv4"].add(v.decode("ascii", errors="replace"))
                            for m in PAT_URL.finditer(data):
                                iocs["url"].add(m.group().decode("ascii", errors="replace"))
                            for m in PAT_DOMAIN.finditer(data):
                                iocs["domain"].add(m.group().decode("ascii", errors="replace"))
                            for m in PAT_B64.finditer(data):
                                v = m.group()
                                if len(v) >= min_b64:
                                    iocs["base64"].add(v[:128].decode("ascii", errors="replace"))
                            for m in PAT_PIPE.finditer(data):
                                iocs["pipe"].add(m.group().decode("utf-8", errors="replace"))
                            for m in PAT_UNC.finditer(data):
                                iocs["unc"].add(m.group().decode("utf-8", errors="replace"))
                            for m in PAT_PATH.finditer(data):
                                iocs["path"].add(m.group().decode("utf-8", errors="replace"))
                            for m in PAT_REGISTRY.finditer(data):
                                iocs["registry"].add(m.group().decode("utf-8", errors="replace"))
                        read_off += chunk_sz

                next_addr = (mbi.BaseAddress or 0) + size
                if next_addr <= addr:
                    break
                addr = next_addr
        finally:
            kernel32.CloseHandle(h)

        # Trim sets and convert to sorted lists
        MAX_PER_TYPE = 500
        result = {
            "pid": pid,
            "ioc_counts": {},
            "iocs": {},
        }
        for k, s in iocs.items():
            lst = sorted(s)[:MAX_PER_TYPE]
            result["iocs"][k] = lst
            result["ioc_counts"][k] = len(lst)

        out = json.dumps(result, indent=2, default=str)
        return ({"command": "memory_ioc_extractor", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #17 – COM Object Hijacking Detector
    # -----------------------------------------------------------------------
    def _endpoint_com_hijack_detector(self, params, timeout):
        """Find COM objects hijacked in HKCU that override system HKCR entries.

        Enumerates every CLSID under ``HKCU\\Software\\Classes\\CLSID`` and
        compares its InprocServer32 / LocalServer32 path against the
        corresponding ``HKCR\\CLSID`` entry.  A user-space override that
        points to a different binary is a COM hijack for persistence or
        privilege escalation.

        Parameters
        ----------
        all_users : bool  Also check HKLM\\SOFTWARE\\Classes\\CLSID for
                          machine-wide suspicious entries (default false).
        """
        self._block_if_not_windows("com_hijack_detector")
        try:
            all_users = str(params.get("all_users", "false")).lower() in ("1", "true", "yes")
        except Exception:
            all_users = False

        import winreg

        def _read_server(root, clsid):
            """Return (InprocServer32, LocalServer32) paths or ('', '')."""
            inproc, local = "", ""
            for sub, field in (("InprocServer32", ""), ("LocalServer32", "")):
                try:
                    k = winreg.OpenKey(root, r"CLSID\%s\%s" % (clsid, sub), 0, winreg.KEY_READ)
                    try:
                        v, _ = winreg.QueryValueEx(k, "")
                        if sub == "InprocServer32":
                            inproc = v or ""
                        else:
                            local = v or ""
                    except Exception:
                        pass
                    winreg.CloseKey(k)
                except Exception:
                    pass
            return inproc, local

        def _read_class_name(root, clsid):
            try:
                k = winreg.OpenKey(root, r"CLSID\%s" % clsid, 0, winreg.KEY_READ)
                v, _ = winreg.QueryValueEx(k, "")
                winreg.CloseKey(k)
                return v or ""
            except Exception:
                return ""

        hijacks   = []
        scanned   = 0
        errors    = []

        # Enumerate HKCU CLSIDs
        try:
            hkcu_clsids = winreg.OpenKey(
                winreg.HKEY_CURRENT_USER,
                r"Software\Classes\CLSID", 0, winreg.KEY_READ,
            )
        except Exception as exc:
            out = json.dumps({"error": "Cannot open HKCU\\Software\\Classes\\CLSID: %s" % exc})
            return ({"command": "com_hijack_detector", "stdout": "", "stderr": out, "exit_code": 1}, out)

        idx = 0
        while True:
            try:
                clsid = winreg.EnumKey(hkcu_clsids, idx)
                idx += 1
            except OSError:
                break
            scanned += 1
            try:
                hkcu_inproc, hkcu_local = _read_server(
                    winreg.HKEY_CURRENT_USER,
                    r"Software\Classes\CLSID\%s" % clsid,
                )
                # Normalise: strip quotes and expand env vars
                hkcu_path = (hkcu_inproc or hkcu_local).strip('"').strip()
                if not hkcu_path:
                    continue

                # Look up the same CLSID in HKCR (the merged view of HKLM+HKCU)
                # We read from HKLM\SOFTWARE\Classes to get the system entry
                sys_inproc, sys_local = _read_server(
                    winreg.HKEY_LOCAL_MACHINE,
                    r"SOFTWARE\Classes\CLSID\%s" % clsid,
                )
                sys_path = (sys_inproc or sys_local).strip('"').strip()

                class_name = _read_class_name(
                    winreg.HKEY_LOCAL_MACHINE,
                    r"SOFTWARE\Classes\CLSID\%s" % clsid,
                )

                if not sys_path:
                    # HKCU defines a CLSID that has no system counterpart – suspicious
                    hijacks.append({
                        "clsid":         clsid,
                        "class_name":    class_name,
                        "hijack_type":   "HKCU_ONLY (no system entry)",
                        "hkcu_path":     hkcu_path,
                        "system_path":   "",
                    })
                elif hkcu_path.lower() != sys_path.lower():
                    hijacks.append({
                        "clsid":         clsid,
                        "class_name":    class_name,
                        "hijack_type":   "OVERRIDE (different binary)",
                        "hkcu_path":     hkcu_path,
                        "system_path":   sys_path,
                    })
            except Exception as exc:
                errors.append({"clsid": clsid, "error": str(exc)})

        winreg.CloseKey(hkcu_clsids)

        result = {
            "clsids_scanned": scanned,
            "hijacks_found":  len(hijacks),
            "hijacks":        hijacks,
            "errors":         errors,
        }
        out = json.dumps(result, indent=2, default=str)
        code = 1 if hijacks else 0
        return ({"command": "com_hijack_detector", "stdout": out, "stderr": "", "exit_code": code}, out)

    # -----------------------------------------------------------------------
    # IR Tool #18 – File System Anomaly Detector
    # -----------------------------------------------------------------------
    def _endpoint_file_anomaly_detector(self, params, timeout):
        """Scan a directory for files with suspicious characteristics.

        Checks: magic-byte vs extension mismatches (PE hidden as .jpg etc.),
        executables in user-writable paths, recently modified system files,
        files with future timestamps (timestomping), zero-byte executables,
        and PE files with no version information.  Each finding is scored 0-100.

        Parameters
        ----------
        path      : str   Directory to scan (required).
        recursive : bool  Recurse into subdirectories (default false).
        hours     : int   Flag files modified in the past N hours (default 24).
        """
        self._block_if_not_windows("file_anomaly_detector")

        path = params.get("path")
        if not path or not isinstance(path, str) or not path.strip():
            raise PermissionError("Missing or invalid 'path' parameter.")
        path = path.strip()
        if "\x00" in path or len(path) > 512:
            raise PermissionError("Invalid path.")
        if not os.path.exists(path):
            raise PermissionError("Path does not exist: %s" % path)

        try:
            recursive = str(params.get("recursive", "false")).lower() in ("1", "true", "yes")
        except Exception:
            recursive = False
        try:
            hours = max(1, min(720, int(params.get("hours", 24))))
        except Exception:
            hours = 24

        import struct
        import datetime as _dt

        MAGIC_TABLE = {
            b"MZ":                    "PE_EXECUTABLE",
            b"\x7FELF":               "ELF_EXECUTABLE",
            b"PK\x03\x04":           "ZIP_ARCHIVE",
            b"\x89PNG":               "PNG_IMAGE",
            b"\xFF\xD8\xFF":         "JPEG_IMAGE",
            b"GIF8":                  "GIF_IMAGE",
            b"%PDF":                  "PDF_DOCUMENT",
            b"Rar!\x1a\x07":         "RAR_ARCHIVE",
            b"7z\xBC\xAF":           "7ZIP_ARCHIVE",
            b"\xD0\xCF\x11\xE0":     "OLE_DOCUMENT",
            b"\x1f\x8b":             "GZIP_ARCHIVE",
        }

        EXEC_EXTS   = {".exe", ".dll", ".sys", ".drv", ".ocx", ".cpl", ".scr", ".com"}
        MEDIA_EXTS  = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff",
                       ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
                       ".txt", ".log", ".csv", ".zip", ".rar", ".7z", ".mp4", ".mp3"}
        SUSP_PATHS  = ("\\temp\\", "\\appdata\\", "\\downloads\\", "\\desktop\\",
                       "\\users\\public\\", "\\programdata\\")
        SYS_PATHS   = (os.environ.get("SystemRoot", r"C:\Windows").lower(),
                       os.environ.get("ProgramFiles", r"C:\Program Files").lower())

        now      = _dt.datetime.utcnow()
        cutoff   = now - _dt.timedelta(hours=hours)
        findings = []
        scanned  = 0

        def _check_file(fp):
            nonlocal scanned
            scanned += 1
            flags   = []
            score   = 0
            try:
                st       = os.stat(fp)
                size     = st.st_size
                mtime    = _dt.datetime.utcfromtimestamp(st.st_mtime)
                ctime    = _dt.datetime.utcfromtimestamp(st.st_ctime)
                ext      = os.path.splitext(fp)[1].lower()
                fp_lower = fp.lower()
                actual_type = None

                if size >= 2:
                    try:
                        with open(fp, "rb") as f:
                            header = f.read(8)
                        for magic, type_name in MAGIC_TABLE.items():
                            if header[: len(magic)] == magic:
                                actual_type = type_name
                                break
                    except Exception:
                        return

                # 1. PE file disguised as media/document
                if actual_type == "PE_EXECUTABLE" and ext in MEDIA_EXTS:
                    flags.append("PE_HIDDEN_AS_%s" % ext.upper().lstrip("."))
                    score += 60

                # 2. Non-executable extension but is an executable magic
                if actual_type in ("PE_EXECUTABLE", "ELF_EXECUTABLE") and ext not in EXEC_EXTS and ext != "":
                    if "PE_HIDDEN" not in " ".join(flags):
                        flags.append("EXECUTABLE_WITH_NON_EXEC_EXT")
                        score += 40

                # 3. Executable in suspicious writable path
                if actual_type in ("PE_EXECUTABLE", "ELF_EXECUTABLE"):
                    for sp in SUSP_PATHS:
                        if sp in fp_lower:
                            flags.append("EXEC_IN_WRITABLE_PATH")
                            score += 30
                            break

                # 4. Recently modified file in system directory
                if mtime >= cutoff:
                    for sp in SYS_PATHS:
                        if fp_lower.startswith(sp):
                            flags.append("RECENTLY_MODIFIED_SYSTEM_FILE")
                            score += 25
                            break

                # 5. Future timestamp (timestomping indicator)
                if mtime > now + _dt.timedelta(minutes=5):
                    flags.append("FUTURE_MTIME_TIMESTAMP")
                    score += 50
                if ctime > now + _dt.timedelta(minutes=5):
                    flags.append("FUTURE_CTIME_TIMESTAMP")
                    score += 50

                # 6. Zero-byte executable
                if size == 0 and ext in EXEC_EXTS:
                    flags.append("ZERO_BYTE_EXECUTABLE")
                    score += 20

                # 7. PE with no version information block
                if actual_type == "PE_EXECUTABLE" and size > 0:
                    try:
                        with open(fp, "rb") as f:
                            data = f.read(min(size, 1048576))
                        if b"VS_VERSION_INFO" not in data and b"FileVersion" not in data:
                            flags.append("PE_NO_VERSION_INFO")
                            score += 15
                    except Exception:
                        pass

                if flags:
                    findings.append({
                        "path":        fp,
                        "size_bytes":  size,
                        "actual_type": actual_type,
                        "extension":   ext,
                        "mtime_utc":   mtime.strftime("%Y-%m-%dT%H:%M:%SZ"),
                        "ctime_utc":   ctime.strftime("%Y-%m-%dT%H:%M:%SZ"),
                        "flags":       flags,
                        "score":       min(score, 100),
                    })
            except Exception:
                pass

        if recursive:
            for root, _dirs, files in os.walk(path):
                for fname in files:
                    _check_file(os.path.join(root, fname))
        else:
            try:
                for fname in os.listdir(path):
                    fp = os.path.join(path, fname)
                    if os.path.isfile(fp):
                        _check_file(fp)
            except Exception:
                pass

        findings.sort(key=lambda x: x["score"], reverse=True)
        result = {
            "scan_path":     path,
            "recursive":     recursive,
            "hours_filter":  hours,
            "files_scanned": scanned,
            "anomalies_found": len(findings),
            "findings":      findings,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "file_anomaly_detector", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #19 – USB / Removable Media History
    # -----------------------------------------------------------------------
    def _endpoint_usb_media_history(self, params, timeout):
        """Reconstruct USB and removable media connection history.

        Reads ``HKLM\\SYSTEM\\CurrentControlSet\\Enum\\USBSTOR`` for device
        metadata and serial numbers, then parses
        ``C:\\Windows\\INF\\setupapi.dev.log`` for first-connect timestamps.
        Also checks ``HKLM\\SYSTEM\\CurrentControlSet\\Enum\\USB`` for the
        raw USB device records.
        """
        del params
        self._block_if_not_windows("usb_media_history")

        import winreg
        import datetime as _dt
        import re as _re

        devices = []
        errors  = []

        # ------------------------------------------------------------------
        # 1. Parse USBSTOR registry hive
        # ------------------------------------------------------------------
        try:
            usbstor = winreg.OpenKey(
                winreg.HKEY_LOCAL_MACHINE,
                r"SYSTEM\CurrentControlSet\Enum\USBSTOR",
                0, winreg.KEY_READ,
            )
            type_idx = 0
            while True:
                try:
                    type_name = winreg.EnumKey(usbstor, type_idx)
                    type_idx += 1
                except OSError:
                    break
                try:
                    type_key = winreg.OpenKey(usbstor, type_name, 0, winreg.KEY_READ)
                except Exception:
                    continue
                inst_idx = 0
                while True:
                    try:
                        serial = winreg.EnumKey(type_key, inst_idx)
                        inst_idx += 1
                    except OSError:
                        break
                    try:
                        inst_key = winreg.OpenKey(type_key, serial, 0, winreg.KEY_READ)

                        def _rval(key, name, default=""):
                            try:
                                v, _ = winreg.QueryValueEx(key, name)
                                return v or default
                            except Exception:
                                return default

                        friendly = _rval(inst_key, "FriendlyName")
                        mfg      = _rval(inst_key, "Mfg")
                        hw_ids   = _rval(inst_key, "HardwareID")
                        location = _rval(inst_key, "LocationInformation")
                        desc     = _rval(inst_key, "DeviceDesc")

                        # Drive letter from HKLM\SYSTEM\MountedDevices
                        # (deferred — we note the serial for correlation)

                        # Last write time of instance key ≈ last connection
                        try:
                            import ctypes
                            # RegQueryInfoKeyW gives the last-write time
                            advapi32 = ctypes.windll.advapi32
                            ft = (ctypes.c_uint64)(0)
                            advapi32.RegQueryInfoKeyW(
                                inst_key.handle if hasattr(inst_key, 'handle') else 0,
                                None, None, None, None, None, None, None, None, None, None,
                                ctypes.byref(ft),
                            )
                            last_write = ft.value
                            if last_write:
                                us = (last_write - 116444736000000000) // 10
                                last_write_iso = (_dt.datetime(1970, 1, 1) + _dt.timedelta(microseconds=us)).strftime("%Y-%m-%dT%H:%M:%SZ")
                            else:
                                last_write_iso = None
                        except Exception:
                            last_write_iso = None

                        devices.append({
                            "device_type":    type_name,
                            "serial":         serial.rstrip("&0").rstrip("&1"),
                            "serial_raw":     serial,
                            "friendly_name":  friendly,
                            "manufacturer":   mfg,
                            "description":    desc,
                            "hardware_id":    hw_ids,
                            "location":       location,
                            "last_write_utc": last_write_iso,
                            "first_connect":  None,  # filled from setupapi below
                        })
                        winreg.CloseKey(inst_key)
                    except Exception as exc:
                        errors.append(str(exc))
                winreg.CloseKey(type_key)
            winreg.CloseKey(usbstor)
        except Exception as exc:
            errors.append("USBSTOR registry read error: %s" % exc)

        # ------------------------------------------------------------------
        # 2. Parse setupapi.dev.log for first-connect timestamps
        # ------------------------------------------------------------------
        setupapi_path = os.path.join(
            os.environ.get("SystemRoot", r"C:\Windows"), "INF", "setupapi.dev.log"
        )
        serial_to_first = {}
        try:
            with open(setupapi_path, "r", errors="replace") as f:
                content = f.read()
            # Lines like: ">>>  [Device Install (Hardware initiated) - SWD\WPDBUSENUM\...]"
            # and:        ">>>  Section start 2024/01/15 14:22:03.456"
            # Find USBSTOR sections and their timestamps
            pattern = _re.compile(
                r"Section start\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})"
                r".*?USBSTOR[^\n]*?([A-Za-z0-9&]+)",
                _re.DOTALL,
            )
            for m in pattern.finditer(content):
                ts_str = m.group(1)
                serial_hint = m.group(2)
                try:
                    ts = _dt.datetime.strptime(ts_str.strip(), "%Y/%m/%d %H:%M:%S").strftime("%Y-%m-%dT%H:%M:%SZ")
                    key = serial_hint[:20].upper()
                    if key not in serial_to_first:
                        serial_to_first[key] = ts
                except Exception:
                    pass
        except Exception:
            pass

        # Correlate first-connect timestamps
        for dev in devices:
            serial_key = dev["serial"][:20].upper() if dev["serial"] else ""
            if serial_key in serial_to_first:
                dev["first_connect"] = serial_to_first[serial_key]

        result = {
            "total_devices": len(devices),
            "errors":        errors,
            "devices":       sorted(devices, key=lambda d: d.get("last_write_utc") or "", reverse=True),
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "usb_media_history", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #20 – Logon Session Enumeration
    # -----------------------------------------------------------------------
    def _endpoint_logon_session_enum(self, params, timeout):
        """Enumerate all active logon sessions via LsaEnumerateLogonSessions.

        Returns session LUID, logon type, authentication package, logon time,
        logon server, DNS domain, and UPN for every session on the host.
        Flags sessions using NTLM on Kerberos-preferred domains and
        interactive sessions with high-privilege accounts.
        """
        del params
        self._block_if_not_windows("logon_session_enum")

        import ctypes
        import ctypes.wintypes
        import struct
        import datetime as _dt

        secur32  = ctypes.windll.secur32
        kernel32 = ctypes.windll.kernel32

        class LUID(ctypes.Structure):
            _fields_ = [("LowPart", ctypes.c_ulong), ("HighPart", ctypes.c_long)]

        class LSA_UNICODE_STRING(ctypes.Structure):
            _fields_ = [
                ("Length",        ctypes.c_ushort),
                ("MaximumLength", ctypes.c_ushort),
                ("Buffer",        ctypes.c_wchar_p),
            ]

        class SECURITY_LOGON_SESSION_DATA(ctypes.Structure):
            _fields_ = [
                ("Size",                   ctypes.wintypes.ULONG),
                ("LogonId",                LUID),
                ("UserName",               LSA_UNICODE_STRING),
                ("LogonDomain",            LSA_UNICODE_STRING),
                ("AuthenticationPackage",  LSA_UNICODE_STRING),
                ("LogonType",              ctypes.wintypes.ULONG),
                ("Session",                ctypes.wintypes.ULONG),
                ("Sid",                    ctypes.c_void_p),
                ("LogonTime",              ctypes.c_int64),
                ("LogonServer",            LSA_UNICODE_STRING),
                ("DnsDomainName",          LSA_UNICODE_STRING),
                ("Upn",                    LSA_UNICODE_STRING),
            ]

        LOGON_TYPES = {
            0: "System", 2: "Interactive", 3: "Network", 4: "Batch",
            5: "Service", 7: "Unlock", 8: "NetworkCleartext",
            9: "NewCredentials", 10: "RemoteInteractive",
            11: "CachedInteractive", 12: "CachedRemoteInteractive",
            13: "CachedUnlock",
        }

        def _ft_to_iso(val):
            if val <= 0:
                return None
            try:
                us = (val - 116444736000000000) // 10
                return (_dt.datetime(1970, 1, 1) + _dt.timedelta(microseconds=us)).strftime("%Y-%m-%dT%H:%M:%SZ")
            except Exception:
                return None

        count    = ctypes.c_ulong(0)
        sessions = ctypes.POINTER(LUID)()

        ret = secur32.LsaEnumerateLogonSessions(ctypes.byref(count), ctypes.byref(sessions))
        if ret != 0:
            out = json.dumps({"error": "LsaEnumerateLogonSessions returned 0x%X" % ret})
            return ({"command": "logon_session_enum", "stdout": "", "stderr": out, "exit_code": 1}, out)

        entries = []
        try:
            for i in range(count.value):
                luid = sessions[i]
                data_ptr = ctypes.POINTER(SECURITY_LOGON_SESSION_DATA)()
                if secur32.LsaGetLogonSessionData(ctypes.byref(luid), ctypes.byref(data_ptr)) != 0:
                    continue
                try:
                    d = data_ptr.contents
                    ltype = d.LogonType
                    auth  = d.AuthenticationPackage.Buffer or ""
                    uname = d.UserName.Buffer or ""
                    dom   = d.LogonDomain.Buffer or ""
                    dns   = d.DnsDomainName.Buffer or ""
                    upn   = d.Upn.Buffer or ""
                    lsrv  = d.LogonServer.Buffer or ""
                    ltime = _ft_to_iso(d.LogonTime)

                    flags = []
                    if auth.upper() == "NTLM" and dns:
                        flags.append("NTLM_ON_DOMAIN (should be Kerberos)")
                    if ltype in (2, 10) and any(k in uname.upper() for k in ("ADMIN", "SYSTEM", "SERVICE")):
                        flags.append("PRIVILEGED_INTERACTIVE_SESSION")

                    entries.append({
                        "luid":            "0x%X%08X" % (luid.HighPart, luid.LowPart),
                        "username":        uname,
                        "domain":          dom,
                        "upn":             upn,
                        "logon_type":      LOGON_TYPES.get(ltype, str(ltype)),
                        "logon_type_id":   ltype,
                        "auth_package":    auth,
                        "session":         d.Session,
                        "logon_time":      ltime,
                        "logon_server":    lsrv,
                        "dns_domain":      dns,
                        "flags":           flags,
                    })
                except Exception:
                    pass
                finally:
                    secur32.LsaFreeReturnBuffer(data_ptr)
        finally:
            secur32.LsaFreeReturnBuffer(sessions)

        result = {
            "total_sessions": len(entries),
            "flagged_count":  sum(1 for e in entries if e["flags"]),
            "sessions":       sorted(entries, key=lambda e: e.get("logon_time") or "", reverse=True),
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "logon_session_enum", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # -----------------------------------------------------------------------
    # IR Tool #21 – Network Share & Open File Enumeration
    # -----------------------------------------------------------------------
    def _endpoint_share_open_file_enum(self, params, timeout):
        """Enumerate all SMB shares, active sessions, and open file handles.

        Uses NetShareEnum (SHARE_INFO_502), NetSessionEnum (SESSION_INFO_10),
        and NetFileEnum (FILE_INFO_3) from netapi32.dll.  Flags hidden
        administrative shares that were not created by the OS, connected
        sessions with unusual idle times, and a high open-file count that
        may indicate staging for lateral movement or ransomware enumeration.
        """
        del params
        self._block_if_not_windows("share_open_file_enum")

        import ctypes
        import ctypes.wintypes

        netapi32 = ctypes.windll.netapi32

        NERR_Success     = 0
        ERROR_MORE_DATA  = 234
        MAX_PREFERRED    = 0xFFFFFFFF  # -1 as DWORD

        STYPE_DISKTREE = 0x0
        STYPE_PRINTQ   = 0x1
        STYPE_DEVICE   = 0x2
        STYPE_IPC      = 0x3
        STYPE_SPECIAL  = 0x80000000

        # --- SHARE_INFO_1 ---
        class SHARE_INFO_1(ctypes.Structure):
            _fields_ = [
                ("shi1_netname", ctypes.c_wchar_p),
                ("shi1_type",    ctypes.wintypes.DWORD),
                ("shi1_remark",  ctypes.c_wchar_p),
            ]

        # --- SESSION_INFO_10 ---
        class SESSION_INFO_10(ctypes.Structure):
            _fields_ = [
                ("sesi10_cname",       ctypes.c_wchar_p),
                ("sesi10_username",    ctypes.c_wchar_p),
                ("sesi10_time",        ctypes.wintypes.DWORD),
                ("sesi10_idle_time",   ctypes.wintypes.DWORD),
            ]

        # --- FILE_INFO_3 ---
        class FILE_INFO_3(ctypes.Structure):
            _fields_ = [
                ("fi3_id",          ctypes.wintypes.DWORD),
                ("fi3_permissions", ctypes.wintypes.DWORD),
                ("fi3_num_locks",   ctypes.wintypes.DWORD),
                ("fi3_pathname",    ctypes.c_wchar_p),
                ("fi3_username",    ctypes.c_wchar_p),
            ]

        shares   = []
        sessions = []
        files    = []
        errors   = []

        # --- Shares ---
        try:
            buf     = ctypes.c_void_p()
            entries = ctypes.wintypes.DWORD(0)
            total   = ctypes.wintypes.DWORD(0)
            ret     = netapi32.NetShareEnum(
                None, 1,
                ctypes.byref(buf),
                MAX_PREFERRED,
                ctypes.byref(entries),
                ctypes.byref(total),
                None,
            )
            if ret in (NERR_Success, ERROR_MORE_DATA) and buf.value:
                arr = (SHARE_INFO_1 * entries.value).from_address(buf.value)
                OS_ADMIN_SHARES = {"ADMIN$", "C$", "D$", "E$", "F$", "IPC$", "PRINT$"}
                for s in arr:
                    stype    = s.shi1_type
                    name     = s.shi1_netname or ""
                    hidden   = name.endswith("$")
                    is_admin = name.upper() in OS_ADMIN_SHARES
                    type_str = {STYPE_DISKTREE: "disk", STYPE_PRINTQ: "print",
                                STYPE_DEVICE: "device", STYPE_IPC: "ipc"}.get(stype & 0xFF, "unknown")
                    special  = bool(stype & STYPE_SPECIAL)
                    flags = []
                    if hidden and not is_admin:
                        flags.append("HIDDEN_NON_OS_SHARE")
                    shares.append({
                        "name":     name,
                        "type":     type_str,
                        "hidden":   hidden,
                        "special":  special,
                        "os_admin": is_admin,
                        "remark":   s.shi1_remark or "",
                        "flags":    flags,
                    })
                netapi32.NetApiBufferFree(buf)
        except Exception as exc:
            errors.append("NetShareEnum: %s" % exc)

        # --- Active sessions ---
        try:
            buf     = ctypes.c_void_p()
            entries = ctypes.wintypes.DWORD(0)
            total   = ctypes.wintypes.DWORD(0)
            ret     = netapi32.NetSessionEnum(
                None, None, None, 10,
                ctypes.byref(buf),
                MAX_PREFERRED,
                ctypes.byref(entries),
                ctypes.byref(total),
                None,
            )
            if ret in (NERR_Success, ERROR_MORE_DATA) and buf.value:
                arr = (SESSION_INFO_10 * entries.value).from_address(buf.value)
                for s in arr:
                    idle = s.sesi10_idle_time
                    flags = []
                    if idle > 3600:
                        flags.append("LONG_IDLE_SESSION_%dh" % (idle // 3600))
                    sessions.append({
                        "client":    s.sesi10_cname or "",
                        "username":  s.sesi10_username or "",
                        "duration_s": s.sesi10_time,
                        "idle_s":    idle,
                        "flags":     flags,
                    })
                netapi32.NetApiBufferFree(buf)
        except Exception as exc:
            errors.append("NetSessionEnum: %s" % exc)

        # --- Open files ---
        try:
            buf     = ctypes.c_void_p()
            entries = ctypes.wintypes.DWORD(0)
            total   = ctypes.wintypes.DWORD(0)
            resume  = ctypes.wintypes.DWORD(0)
            ret     = netapi32.NetFileEnum(
                None, None, None, 3,
                ctypes.byref(buf),
                MAX_PREFERRED,
                ctypes.byref(entries),
                ctypes.byref(total),
                ctypes.byref(resume),
            )
            if ret in (NERR_Success, ERROR_MORE_DATA) and buf.value:
                arr = (FILE_INFO_3 * entries.value).from_address(buf.value)
                for f in arr:
                    files.append({
                        "file_id":    f.fi3_id,
                        "path":       f.fi3_pathname or "",
                        "username":   f.fi3_username or "",
                        "locks":      f.fi3_num_locks,
                        "permissions": f.fi3_permissions,
                    })
                netapi32.NetApiBufferFree(buf)
        except Exception as exc:
            errors.append("NetFileEnum: %s" % exc)

        result = {
            "shares_count":      len(shares),
            "sessions_count":    len(sessions),
            "open_files_count":  len(files),
            "suspicious_shares": [s for s in shares if s.get("flags")],
            "shares":            shares,
            "sessions":          sessions,
            "open_files":        files,
            "errors":            errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "share_open_file_enum", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # credential_theft_indicators
    # ------------------------------------------------------------------
    def _endpoint_credential_theft_indicators(self, params, timeout):
        """
        Hunt for artifacts that commonly indicate credential-theft activity:
          - LSASS minidumps in temp/public directories
          - SAM / SYSTEM / SECURITY hive copies outside of their canonical paths
          - procdump.exe / procdump64.exe on disk
          - comsvcs.dll MiniDump references in recent PowerShell EID 4104 logs
          - Security event log EID 4656/4663 with lsass.exe as the object
        Returns a structured JSON summary with a findings list.
        """
        self._block_if_not_windows("credential_theft_indicators")
        import ctypes
        import ctypes.wintypes
        import struct

        findings = []
        errors   = []

        # ── 1. Search common dump staging dirs for .dmp files referencing lsass ──
        staging_dirs = [
            os.environ.get("TEMP", ""),
            os.environ.get("TMP", ""),
            r"C:\Windows\Temp",
            r"C:\Users\Public",
            r"C:\PerfLogs",
        ]
        for d in staging_dirs:
            if not d or not os.path.isdir(d):
                continue
            try:
                for entry in os.scandir(d):
                    if entry.is_file() and entry.name.lower().endswith(".dmp"):
                        try:
                            size = entry.stat().st_size
                        except OSError:
                            size = 0
                        findings.append({
                            "type":  "lsass_dump_candidate",
                            "path":  entry.path,
                            "size":  size,
                            "severity": "HIGH",
                        })
            except Exception as exc:
                errors.append("staging_dir_scan(%s): %s" % (d, exc))

        # ── 2. SAM / SYSTEM / SECURITY hive copies ──
        hive_names = {"sam", "system", "security", "ntds.dit"}
        suspicious_hive_dirs = [
            os.environ.get("TEMP", ""),
            os.environ.get("TMP", ""),
            r"C:\Windows\Temp",
            r"C:\Users\Public",
            r"C:\PerfLogs",
        ]
        for d in suspicious_hive_dirs:
            if not d or not os.path.isdir(d):
                continue
            try:
                for entry in os.scandir(d):
                    if entry.is_file() and entry.name.lower() in hive_names:
                        findings.append({
                            "type":     "registry_hive_copy",
                            "path":     entry.path,
                            "severity": "CRITICAL",
                        })
            except Exception as exc:
                errors.append("hive_scan(%s): %s" % (d, exc))

        # ── 3. procdump on disk (common tool used for LSASS dumping) ──
        procdump_names = {"procdump.exe", "procdump64.exe", "procdump64a.exe"}
        search_dirs = [
            r"C:\Windows\Temp",
            os.environ.get("TEMP", ""),
            os.environ.get("TMP", ""),
            r"C:\Users\Public",
            r"C:\PerfLogs",
            r"C:\Windows\System32",
            r"C:\Windows\SysWOW64",
        ]
        for d in search_dirs:
            if not d or not os.path.isdir(d):
                continue
            try:
                for entry in os.scandir(d):
                    if entry.is_file() and entry.name.lower() in procdump_names:
                        findings.append({
                            "type":     "procdump_on_disk",
                            "path":     entry.path,
                            "severity": "HIGH",
                        })
            except Exception as exc:
                errors.append("procdump_scan(%s): %s" % (d, exc))

        # ── 4. PowerShell EID 4104 references to comsvcs MiniDump ──
        ps_script = (
            r"try{"
            r"$evts=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' "
            r"-FilterXPath ""*[System[EventID=4104]]"" -MaxEvents 2000 -ErrorAction SilentlyContinue;"
            r"$hits=@();"
            r"foreach($e in $evts){"
            r"$msg=$e.Message;"
            r"if($msg -match 'comsvcs' -or $msg -match 'MiniDump' -or $msg -match 'lsass'){"
            r"$hits += [PSCustomObject]@{time=$e.TimeCreated.ToString('o');preview=$msg.Substring(0,[Math]::Min(300,$msg.Length))};"
            r"}"
            r"};"
            r"$hits | ConvertTo-Json -Depth 3;"
            r"}catch{Write-Output '[]'}"
        )
        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                min(timeout, 30),
            )
            raw_out = (out or "").strip()
            if raw_out and raw_out != "[]":
                try:
                    hits = json.loads(raw_out)
                    if isinstance(hits, dict):
                        hits = [hits]
                    for h in hits:
                        findings.append({
                            "type":     "comsvcs_minidump_ps",
                            "time":     h.get("time", ""),
                            "preview":  h.get("preview", ""),
                            "severity": "CRITICAL",
                        })
                except Exception:
                    pass
        except Exception as exc:
            errors.append("ps_4104_comsvcs: %s" % exc)

        # ── 5. Security EID 4656/4663 – object access on lsass.exe ──
        ps_lsass_access = (
            r"try{"
            r"$xp=""*[System[EventID=4656 or EventID=4663] and "
            r"EventData[Data[@Name='ObjectName'] and Data[contains(.,'lsass')]]]"";"
            r"$evts=Get-WinEvent -LogName Security -FilterXPath $xp -MaxEvents 500 "
            r"-ErrorAction SilentlyContinue;"
            r"$results=@();"
            r"foreach($e in $evts){"
            r"$xml=[xml]$e.ToXml();"
            r"$d=$xml.Event.EventData.Data;"
            r"$row=[PSCustomObject]@{time=$e.TimeCreated.ToString('o');eid=$e.Id;"
            r"subject=$($d|?{$_.Name -eq 'SubjectUserName'}|Select -Exp '#text');"
            r"process=$($d|?{$_.Name -eq 'ProcessName'}|Select -Exp '#text');"
            r"object=$($d|?{$_.Name -eq 'ObjectName'}|Select -Exp '#text')};"
            r"$results += $row;"
            r"};"
            r"$results | ConvertTo-Json -Depth 3;"
            r"}catch{Write-Output '[]'}"
        )
        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_lsass_access],
                min(timeout, 45),
            )
            raw_out = (out or "").strip()
            if raw_out and raw_out != "[]":
                try:
                    hits = json.loads(raw_out)
                    if isinstance(hits, dict):
                        hits = [hits]
                    for h in hits:
                        findings.append({
                            "type":     "lsass_object_access",
                            "eid":      h.get("eid"),
                            "time":     h.get("time", ""),
                            "subject":  h.get("subject", ""),
                            "process":  h.get("process", ""),
                            "object":   h.get("object", ""),
                            "severity": "HIGH",
                        })
                except Exception:
                    pass
        except Exception as exc:
            errors.append("lsass_object_access: %s" % exc)

        result = {
            "total_findings": len(findings),
            "critical": [f for f in findings if f.get("severity") == "CRITICAL"],
            "high":     [f for f in findings if f.get("severity") == "HIGH"],
            "findings": findings,
            "errors":   errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "credential_theft_indicators", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # defender_exclusion_audit
    # ------------------------------------------------------------------
    def _endpoint_defender_exclusion_audit(self, params, timeout):
        """
        Read all Windows Defender exclusion lists from the registry.
        Returns paths, processes, extensions, IP addresses, and temporary
        exclusions that may indicate an attacker has blinded AV coverage.
        """
        self._block_if_not_windows("defender_exclusion_audit")
        import winreg

        base_key = r"SOFTWARE\Microsoft\Windows Defender\Exclusions"
        sub_keys = {
            "Paths":      "path_exclusions",
            "Processes":  "process_exclusions",
            "Extensions": "extension_exclusions",
            "IpAddresses": "ip_exclusions",
            "TemporaryPaths": "temporary_path_exclusions",
        }

        result     = {"exclusions": {}, "total": 0, "errors": []}
        all_values = []

        for sub, label in sub_keys.items():
            entries = []
            key_path = base_key + "\\" + sub
            try:
                hkey = winreg.OpenKey(
                    winreg.HKEY_LOCAL_MACHINE,
                    key_path,
                    0,
                    winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
                )
                idx = 0
                while True:
                    try:
                        name, data, _ = winreg.EnumValue(hkey, idx)
                        entries.append({"value": name, "data": data})
                        all_values.append({"type": label, "value": name, "data": data})
                        idx += 1
                    except OSError:
                        break
                winreg.CloseKey(hkey)
            except FileNotFoundError:
                pass
            except Exception as exc:
                result["errors"].append("%s: %s" % (key_path, exc))
            result["exclusions"][label] = entries

        result["total"] = len(all_values)
        result["all_exclusions"] = all_values

        # Flag suspicious patterns: user temp paths, writable system dirs, no-extension processes
        suspicious = []
        for entry in all_values:
            v = str(entry.get("value", "")).lower()
            if any(p in v for p in [r"\appdata\\", r"\temp\\", r"\tmp\\", r"\public\\"]):
                suspicious.append({"reason": "user_writable_path", **entry})
            elif entry.get("type") == "extension_exclusions" and not v.startswith("."):
                suspicious.append({"reason": "extension_no_dot", **entry})
        result["suspicious"] = suspicious

        out = json.dumps(result, indent=2, default=str)
        return ({"command": "defender_exclusion_audit", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # kerberos_ticket_inspect
    # ------------------------------------------------------------------
    def _endpoint_kerberos_ticket_inspect(self, params, timeout):
        """
        Inspect Kerberos tickets in the current logon session (klist) and
        query Security event log for EID 4769 (Kerberos Service Ticket Operations)
        with RC4/DES encryption types that can indicate pass-the-ticket or
        golden/silver ticket abuse. Flags unusual SPNs and forged-ticket patterns.
        """
        self._block_if_not_windows("kerberos_ticket_inspect")

        # ── 1. klist current tickets ──
        klist_result = {}
        klist_raw = ""
        try:
            code, out, err = self.command_runner(["klist"], min(timeout, 15))
            klist_raw = (out or "") + ("\n" + (err or "") if err else "")
            klist_result = {"exit_code": code, "output": klist_raw}
        except Exception as exc:
            klist_result = {"error": str(exc)}

        # ── 2. EID 4769 with weak encryption types (RC4=0x17, DES=0x3) ──
        ps_script = (
            r"try{"
            r"$xp=""*[System[EventID=4769] and EventData[Data[@Name='TicketEncryptionType'] "
            r"and (Data='0x17' or Data='0x3' or Data='0x1')]]"";"
            r"$evts=Get-WinEvent -LogName Security -FilterXPath $xp -MaxEvents 500 "
            r"-ErrorAction SilentlyContinue;"
            r"$results=@();"
            r"foreach($e in $evts){"
            r"$xml=[xml]$e.ToXml();"
            r"$d=$xml.Event.EventData.Data;"
            r"$results += [PSCustomObject]@{"
            r"time=$e.TimeCreated.ToString('o');"
            r"account=$($d|?{$_.Name -eq 'TargetUserName'}|Select -Exp '#text');"
            r"domain=$($d|?{$_.Name -eq 'TargetDomainName'}|Select -Exp '#text');"
            r"service=$($d|?{$_.Name -eq 'ServiceName'}|Select -Exp '#text');"
            r"enc_type=$($d|?{$_.Name -eq 'TicketEncryptionType'}|Select -Exp '#text');"
            r"client_addr=$($d|?{$_.Name -eq 'IpAddress'}|Select -Exp '#text');"
            r"ticket_opts=$($d|?{$_.Name -eq 'TicketOptions'}|Select -Exp '#text');"
            r"};"
            r"};"
            r"$results | ConvertTo-Json -Depth 3;"
            r"}catch{Write-Output '[]'}"
        )

        weak_enc_hits = []
        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                min(timeout, 60),
            )
            raw = (out or "").strip()
            if raw and raw != "[]":
                parsed = json.loads(raw) if raw.startswith("[") or raw.startswith("{") else []
                if isinstance(parsed, dict):
                    parsed = [parsed]
                for item in parsed:
                    item["flag"] = "weak_encryption"
                    weak_enc_hits.append(item)
        except Exception as exc:
            weak_enc_hits = [{"error": str(exc)}]

        # ── 3. EID 4769 with abnormal ticket lifetimes or forged attributes ──
        # Golden tickets often have 10-year lifetimes (600000 minutes); flag
        # EndTime far in the future by parsing klist output
        golden_ticket_indicators = []
        for line in klist_raw.splitlines():
            line_lower = line.lower()
            if "end time" in line_lower or "renew until" in line_lower:
                # Try to extract the year
                m = re.search(r"20(\d{2})", line)
                if m:
                    year = 2000 + int(m.group(1))
                    if year > 2030:
                        golden_ticket_indicators.append({
                            "reason": "suspicious_ticket_lifetime",
                            "line": line.strip(),
                            "year": year,
                        })

        # ── 4. Unusual SPNs (non-standard service classes) ──
        known_spn_prefixes = {
            "host", "http", "ldap", "cifs", "gc", "rpc", "mssqlsvc", "termsrv",
            "wsman", "exchangemdb", "smtp", "pop3", "imap", "ftp", "dns",
        }
        unusual_spn_hits = []
        for item in weak_enc_hits:
            svc = str(item.get("service", "")).lower()
            prefix = svc.split("/")[0] if "/" in svc else svc
            if prefix and prefix not in known_spn_prefixes and not prefix.startswith("krbtgt"):
                unusual_spn_hits.append({
                    "service": item.get("service"),
                    "account": item.get("account"),
                    "time":    item.get("time"),
                    "reason":  "unusual_spn_prefix",
                })

        result = {
            "klist": klist_result,
            "weak_encryption_events": weak_enc_hits,
            "unusual_spn_hits": unusual_spn_hits,
            "golden_ticket_indicators": golden_ticket_indicators,
            "summary": {
                "weak_enc_count":   len(weak_enc_hits),
                "unusual_spn_count": len(unusual_spn_hits),
                "golden_ticket_indicators": len(golden_ticket_indicators),
            },
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "kerberos_ticket_inspect", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # scheduled_task_deep
    # ------------------------------------------------------------------
    def _endpoint_scheduled_task_deep(self, params, timeout):
        """
        Deep analysis of all scheduled tasks: exports full XML, decodes any
        base64-encoded command arguments, flags inline scripts, obfuscated
        PowerShell, and tasks whose action binary lives in a suspicious path.
        """
        self._block_if_not_windows("scheduled_task_deep")

        ps_script = (
            r"$tasks=Get-ScheduledTask -ErrorAction SilentlyContinue;"
            r"$results=@();"
            r"foreach($t in $tasks){"
            r"$xml=Export-ScheduledTask -TaskName $t.TaskName -TaskPath $t.TaskPath "
            r"-ErrorAction SilentlyContinue;"
            r"$actions=@();"
            r"foreach($a in $t.Actions){"
            r"$act=[PSCustomObject]@{type=$a.GetType().Name;execute=$a.Execute;"
            r"arguments=$a.Arguments;working_dir=$a.WorkingDirectory};"
            r"# Decode base64 if present in arguments"
            r"if($a.Arguments -match '-[Ee][Nn][Cc]'{"
            r"){"
            r"$b64=($a.Arguments -split '\s+' | Select-Object -Last 1);"
            r"try{$decoded=[System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($b64));"
            r"$act | Add-Member -NotePropertyName decoded_arg -NotePropertyValue $decoded -Force;"
            r"}catch{}"
            r"};"
            r"$actions += $act;"
            r"};"
            r"$triggers=@();"
            r"foreach($tr in $t.Triggers){"
            r"$triggers += [PSCustomObject]@{type=$tr.GetType().Name;"
            r"enabled=$tr.Enabled;start=$tr.StartBoundary;repeat=$tr.RepetitionInterval};"
            r"};"
            r"$results += [PSCustomObject]@{"
            r"name=$t.TaskName;path=$t.TaskPath;state=$t.State;"
            r"author=$t.Author;description=$t.Description;"
            r"run_as_user=$t.Principal.UserId;run_level=$t.Principal.RunLevel;"
            r"actions=$actions;triggers=$triggers;"
            r"last_run=$t.LastRunTime;next_run=$t.NextRunTime;xml=$xml;"
            r"};"
            r"};"
            r"$results | ConvertTo-Json -Depth 10 -Compress"
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        raw_out = (out or "").strip()

        tasks = []
        flagged = []
        try:
            parsed = json.loads(raw_out) if raw_out else []
            if isinstance(parsed, dict):
                parsed = [parsed]
            tasks = parsed
        except Exception:
            tasks = []

        # ── Flag suspicious patterns ──
        suspicious_paths = [
            r"\temp\\", r"\tmp\\", r"\appdata\\", r"\public\\",
            r"\programdata\\", "powershell", "cmd /c", "wscript", "cscript",
            "mshta", "regsvr32", "rundll32", "certutil", "bitsadmin",
        ]
        for task in tasks:
            for action in (task.get("actions") or []):
                exe = str(action.get("execute", "")).lower()
                args = str(action.get("arguments", "")).lower()
                flags = []
                for sp in suspicious_paths:
                    if sp in exe or sp in args:
                        flags.append(sp)
                if action.get("decoded_arg"):
                    flags.append("base64_encoded_args")
                if flags:
                    flagged.append({
                        "task": task.get("name"),
                        "path": task.get("path"),
                        "execute": action.get("execute"),
                        "arguments": action.get("arguments"),
                        "decoded_arg": action.get("decoded_arg"),
                        "run_as": task.get("run_as_user"),
                        "run_level": task.get("run_level"),
                        "flags": flags,
                    })

        result = {
            "total_tasks": len(tasks),
            "flagged_count": len(flagged),
            "flagged_tasks": flagged,
            "all_tasks": tasks,
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "scheduled_task_deep", "stdout": out_str, "stderr": err or "", "exit_code": code}, out_str)

    # ------------------------------------------------------------------
    # dll_hijack_detector
    # ------------------------------------------------------------------
    def _endpoint_dll_hijack_detector(self, params, timeout):
        """
        Detect potential DLL hijacking by comparing the on-disk path of each
        DLL loaded by running processes against its expected location
        (System32 / SysWOW64 / WinSxS / known-good dirs). Uses
        EnumProcessModulesEx + GetModuleFileNameExW via ctypes so no child
        processes are spawned. Also checks DLL search order for key
        application directories supplied via params.
        """
        self._block_if_not_windows("dll_hijack_detector")
        import ctypes
        import ctypes.wintypes

        findings  = []
        errors    = []

        PROCESS_QUERY_INFORMATION = 0x0400
        PROCESS_VM_READ           = 0x0010
        TH32CS_SNAPPROCESS        = 0x00000002
        LIST_MODULES_ALL          = 0x03

        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        psapi    = ctypes.WinDLL("psapi",    use_last_error=True)

        class PROCESSENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize",              ctypes.wintypes.DWORD),
                ("cntUsage",            ctypes.wintypes.DWORD),
                ("th32ProcessID",       ctypes.wintypes.DWORD),
                ("th32DefaultHeapID",   ctypes.POINTER(ctypes.c_ulong)),
                ("th32ModuleID",        ctypes.wintypes.DWORD),
                ("cntThreads",          ctypes.wintypes.DWORD),
                ("th32ParentProcessID", ctypes.wintypes.DWORD),
                ("pcPriClassBase",      ctypes.c_long),
                ("dwFlags",             ctypes.wintypes.DWORD),
                ("szExeFile",           ctypes.c_char * 260),
            ]

        # Known safe prefixes (lowercase)
        safe_prefixes = (
            r"c:\windows\system32",
            r"c:\windows\syswow64",
            r"c:\windows\winsxs",
            r"c:\windows\microsoft.net",
            r"c:\windows\assembly",
            r"c:\program files",
            r"c:\program files (x86)",
        )

        # Enumerate processes via CreateToolhelp32Snapshot
        snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
        if snap == ctypes.wintypes.HANDLE(-1).value:
            return ({"command": "dll_hijack_detector", "stdout": "{}", "stderr": "snapshot failed", "exit_code": 1}, "snapshot failed")

        pe = PROCESSENTRY32()
        pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
        pids = []
        try:
            if kernel32.Process32First(snap, ctypes.byref(pe)):
                while True:
                    pids.append((pe.th32ProcessID, pe.szExeFile.decode("cp1252", errors="replace")))
                    if not kernel32.Process32Next(snap, ctypes.byref(pe)):
                        break
        finally:
            kernel32.CloseHandle(snap)

        buf = (ctypes.c_wchar * 1024)()

        for pid, proc_name in pids:
            hproc = kernel32.OpenProcess(
                PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid
            )
            if not hproc:
                continue
            try:
                mods = (ctypes.wintypes.HMODULE * 1024)()
                needed = ctypes.wintypes.DWORD(0)
                if not psapi.EnumProcessModulesEx(
                    hproc, mods, ctypes.sizeof(mods), ctypes.byref(needed), LIST_MODULES_ALL
                ):
                    continue
                mod_count = needed.value // ctypes.sizeof(ctypes.wintypes.HMODULE)
                for i in range(mod_count):
                    if not psapi.GetModuleFileNameExW(hproc, mods[i], buf, 1024):
                        continue
                    dll_path = buf.value.lower()
                    dll_name = os.path.basename(dll_path)
                    if any(dll_path.startswith(sp) for sp in safe_prefixes):
                        continue
                    # Flag: loaded from user/temp/appdata/public dir
                    suspicious_load_dirs = [
                        r"\users\\", r"\temp\\", r"\tmp\\",
                        r"\appdata\\", r"\public\\", r"\programdata\\",
                    ]
                    for sp in suspicious_load_dirs:
                        if sp in dll_path:
                            findings.append({
                                "pid":      pid,
                                "process":  proc_name,
                                "dll":      dll_name,
                                "path":     buf.value,
                                "reason":   "loaded_from_suspicious_dir",
                                "flagged":  sp,
                                "severity": "HIGH",
                            })
                            break
            except Exception as exc:
                errors.append("pid %d: %s" % (pid, exc))
            finally:
                kernel32.CloseHandle(hproc)

        result = {
            "total_findings": len(findings),
            "findings":       findings,
            "errors":         errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "dll_hijack_detector", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # certificate_store_audit
    # ------------------------------------------------------------------
    def _endpoint_certificate_store_audit(self, params, timeout):
        """
        Audit the Windows certificate stores (My, Root, CA, AuthRoot) for:
          - Self-signed certificates (issuer == subject)
          - Expired certificates
          - Certificates expiring within 30 days
          - Short-lived certificates (lifetime < 90 days)
          - Known malicious thumbprints (configurable via params.bad_thumbprints)
          - Certificates with subject/issuer keywords that look unusual
        Uses PowerShell's PKI cmdlets for cross-platform compatibility.
        """
        self._block_if_not_windows("certificate_store_audit")

        # Optional list of known-bad SHA1 thumbprints (comma-separated)
        bad_thumbprints_raw = params.get("bad_thumbprints", "")
        bad_thumbprints = set()
        if isinstance(bad_thumbprints_raw, str):
            for t in bad_thumbprints_raw.split(","):
                t = t.strip().lower().replace(" ", "")
                if t:
                    bad_thumbprints.add(t)

        ps_script = (
            r"$stores=@('My','Root','CA','AuthRoot','TrustedPublisher','Disallowed');"
            r"$now=[DateTime]::UtcNow;"
            r"$soon=$now.AddDays(30);"
            r"$results=@();"
            r"foreach($store in $stores){"
            r"try{"
            r"$s=New-Object System.Security.Cryptography.X509Certificates.X509Store($store,'LocalMachine');"
            r"$s.Open('ReadOnly');"
            r"foreach($cert in $s.Certificates){"
            r"$lifetime=($cert.NotAfter - $cert.NotBefore).TotalDays;"
            r"$results += [PSCustomObject]@{"
            r"store=$store;"
            r"subject=$cert.Subject;"
            r"issuer=$cert.Issuer;"
            r"thumbprint=$cert.Thumbprint;"
            r"serial=$cert.SerialNumber;"
            r"not_before=$cert.NotBefore.ToString('o');"
            r"not_after=$cert.NotAfter.ToString('o');"
            r"lifetime_days=[math]::Round($lifetime,1);"
            r"has_private_key=$cert.HasPrivateKey;"
            r"self_signed=($cert.Subject -eq $cert.Issuer);"
            r"expired=($cert.NotAfter -lt $now);"
            r"expiring_soon=($cert.NotAfter -ge $now -and $cert.NotAfter -le $soon);"
            r"short_lived=($lifetime -lt 90);"
            r"friendly_name=$cert.FriendlyName;"
            r"};"
            r"};"
            r"$s.Close();"
            r"}catch{}"
            r"};"
            r"$results | ConvertTo-Json -Depth 3 -Compress"
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        raw_out = (out or "").strip()

        certs = []
        try:
            parsed = json.loads(raw_out) if raw_out else []
            if isinstance(parsed, dict):
                parsed = [parsed]
            certs = parsed
        except Exception:
            certs = []

        # Post-process: flag bad thumbprints and suspicious subjects
        flagged = []
        for cert in certs:
            flags = []
            thumb = str(cert.get("thumbprint", "")).lower().replace(" ", "")
            if thumb in bad_thumbprints:
                flags.append("known_malicious_thumbprint")
            if cert.get("self_signed"):
                flags.append("self_signed")
            if cert.get("expired"):
                flags.append("expired")
            if cert.get("expiring_soon"):
                flags.append("expiring_soon")
            if cert.get("short_lived"):
                flags.append("short_lived_cert")
            subject = str(cert.get("subject", "")).lower()
            if any(k in subject for k in ["localhost", "test", "temp", "fake", "debug"]):
                flags.append("suspicious_subject")
            if flags:
                cert["flags"] = flags
                flagged.append(cert)

        result = {
            "total_certs":   len(certs),
            "flagged_count": len(flagged),
            "flagged":       flagged,
            "all_certs":     certs,
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "certificate_store_audit", "stdout": out_str, "stderr": err or "", "exit_code": code}, out_str)

    # ------------------------------------------------------------------
    # ntfs_journal_analysis
    # ------------------------------------------------------------------
    def _endpoint_ntfs_journal_analysis(self, params, timeout):
        """
        Parse the NTFS Change Journal ($UsnJrnl:$J) on the specified volume
        (default C:) for recently deleted, renamed, or modified files in
        suspicious directories. Uses fsutil usn readjournal + PowerShell
        fallback. Flags high-value paths: System32, temp, user profiles.
        """
        self._block_if_not_windows("ntfs_journal_analysis")

        volume = params.get("volume", "C:")
        # Sanitise: allow only drive-letter colon form
        if not re.match(r"^[A-Za-z]:$", volume):
            raise PermissionError("volume must be a drive letter (e.g. C:)")

        hours = int(params.get("hours", 2))
        if hours < 1 or hours > 72:
            hours = 2

        # Use PowerShell to read USN journal and filter by timestamp
        ps_script = (
            r"$vol='%(vol)s';"
            r"$cutoff=(Get-Date).AddHours(-%(hours)d);"
            r"$raw=fsutil usn readjournal $vol csv 2>&1;"
            r"if($LASTEXITCODE -ne 0){Write-Output '[]';exit};"
            r"$lines=$raw -split '\r?\n' | Where-Object {$_ -match ','};"
            r"$suspicious_paths=@('\Temp\','\Tmp\','\AppData\','\Public\',"
            r"'\ProgramData\','\System32\','\SysWOW64\','\Windows\Temp\');"
            r"$reasons_map=@{0x1='DATA_OVERWRITE';0x2='DATA_EXTEND';0x4='DATA_TRUNCATION';"
            r"0x100='FILE_CREATE';0x200='FILE_DELETE';0x800='RENAME_OLD_NAME';0x1000='RENAME_NEW_NAME'};"
            r"$results=@();"
            r"foreach($line in $lines[1..([Math]::Min($lines.Length-1,50000))]){"
            r"$cols=$line -split ',';"
            r"if($cols.Length -lt 5){continue};"
            r"$fname=$cols[4].Trim('\"');"
            r"$reason_raw=$cols[3].Trim('\"');"
            r"$ts_raw=$cols[1].Trim('\"');"
            r"try{$ts=[DateTime]::Parse($ts_raw);}catch{continue};"
            r"if($ts -lt $cutoff){continue};"
            r"$match=$false;"
            r"foreach($sp in $suspicious_paths){if($fname -like ""*$sp*""){$match=$true;break}};"
            r"if(-not $match){continue};"
            r"$results += [PSCustomObject]@{timestamp=$ts.ToString('o');"
            r"filename=$fname;reason=$reason_raw};"
            r"};"
            r"$results | ConvertTo-Json -Depth 3 -Compress"
        ) % {"vol": volume, "hours": hours}

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        raw_out = (out or "").strip()

        entries = []
        try:
            parsed = json.loads(raw_out) if raw_out and raw_out != "[]" else []
            if isinstance(parsed, dict):
                parsed = [parsed]
            entries = parsed
        except Exception:
            entries = []

        # Classify by reason code
        deletions = [e for e in entries if "DELETE" in str(e.get("reason", "")).upper()]
        renames   = [e for e in entries if "RENAME" in str(e.get("reason", "")).upper()]
        creates   = [e for e in entries if "CREATE" in str(e.get("reason", "")).upper()]

        result = {
            "volume":           volume,
            "hours_back":       hours,
            "total_entries":    len(entries),
            "deletions":        deletions,
            "renames":          renames,
            "creates":          creates,
            "all_entries":      entries,
            "stderr":           err or "",
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "ntfs_journal_analysis", "stdout": out_str, "stderr": err or "", "exit_code": code}, out_str)

    # ------------------------------------------------------------------
    # ad_recon_indicators
    # ------------------------------------------------------------------
    def _endpoint_ad_recon_indicators(self, params, timeout):
        """
        Detect Active Directory reconnaissance activity on this host:
          - Security EID 4688 with known recon tool names in the NewProcessName
            (BloodHound, SharpHound, ADFind, net.exe, nltest, dsquery, ldifde...)
          - Security EID 4662 (object access on AD objects — LDAP enumeration)
          - Large LDAP query volumes detected via 'Microsoft-Windows-LDAP-Client/Debug'
            or Netlogon log correlation
          - Net command (net user /domain, net group, net accounts) in cmdline
        """
        self._block_if_not_windows("ad_recon_indicators")

        hours = int(params.get("hours", 24))
        if hours < 1 or hours > 168:
            hours = 24

        recon_patterns = [
            "bloodhound", "sharphound", "adfind", "adidnsdump",
            "powerview", "get-aduser", "get-adgroup", "get-adcomputer",
            "invoke-recon", "ldapdomaindump", "windapsearch",
            "nltest", "dsquery", "dsget", "ldifde", "csvde",
        ]
        net_recon_args = [
            "/domain", "group /domain", "accounts /domain",
            "user /domain", "view /domain",
        ]

        ps_script = (
            r"$hours=%(hours)d;"
            r"$cutoff=(Get-Date).AddHours(-$hours);"
            r"$results=@();"
            # EID 4688 – process creation (requires process creation auditing)
            r"try{"
            r"$xp=""*[System[EventID=4688 and TimeCreated[@SystemTime>='$($cutoff.ToUniversalTime().ToString(""yyyy-MM-ddTHH:mm:ss.fffffffZ""))']]"";"
            r"$evts=Get-WinEvent -LogName Security -FilterXPath $xp -MaxEvents 5000 "
            r"-ErrorAction SilentlyContinue;"
            r"$patterns=@(%(patterns)s);"
            r"foreach($e in $evts){"
            r"$xml=[xml]$e.ToXml();"
            r"$d=$xml.Event.EventData.Data;"
            r"$proc=$($d|?{$_.Name -eq 'NewProcessName'}|Select -Exp '#text');"
            r"$cmdline=$($d|?{$_.Name -eq 'CommandLine'}|Select -Exp '#text');"
            r"$subj=$($d|?{$_.Name -eq 'SubjectUserName'}|Select -Exp '#text');"
            r"$matched=$false;"
            r"foreach($p in $patterns){if($proc -match $p -or $cmdline -match $p){$matched=$true;break}};"
            r"if($matched){"
            r"$results += [PSCustomObject]@{source='EID_4688';time=$e.TimeCreated.ToString('o');"
            r"user=$subj;process=$proc;cmdline=$cmdline};"
            r"}"
            r"}"
            r"}catch{}"
            # EID 4662 – AD object access (LDAP recon)
            r"try{"
            r"$xp2=""*[System[EventID=4662 and TimeCreated[@SystemTime>='$($cutoff.ToUniversalTime().ToString(""yyyy-MM-ddTHH:mm:ss.fffffffZ""))']]"";"
            r"$evts2=Get-WinEvent -LogName Security -FilterXPath $xp2 -MaxEvents 2000 "
            r"-ErrorAction SilentlyContinue;"
            r"$ldap_counts=@{};"
            r"foreach($e in $evts2){"
            r"$xml=[xml]$e.ToXml();"
            r"$d=$xml.Event.EventData.Data;"
            r"$subj=$($d|?{$_.Name -eq 'SubjectUserName'}|Select -Exp '#text');"
            r"if($subj -match '\$$'){continue};"   # skip machine accounts
            r"$ldap_counts[$subj]=($ldap_counts[$subj] -as [int]) + 1;"
            r"};"
            r"foreach($k in $ldap_counts.Keys){"
            r"if($ldap_counts[$k] -ge 20){"
            r"$results += [PSCustomObject]@{source='EID_4662_bulk';time=$cutoff.ToString('o');"
            r"user=$k;process='LDAP';cmdline='object_access_count='+$ldap_counts[$k]};"
            r"}"
            r"};"
            r"}catch{}"
            r"$results | ConvertTo-Json -Depth 3 -Compress"
        ) % {
            "hours": hours,
            "patterns": ",".join(["'%s'" % p for p in recon_patterns]),
        }

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        raw_out = (out or "").strip()

        hits = []
        try:
            parsed = json.loads(raw_out) if raw_out and raw_out != "[]" else []
            if isinstance(parsed, dict):
                parsed = [parsed]
            hits = parsed
        except Exception:
            hits = []

        result = {
            "hours_back":  hours,
            "total_hits":  len(hits),
            "recon_events": hits,
            "errors": err or "",
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "ad_recon_indicators", "stdout": out_str, "stderr": err or "", "exit_code": code}, out_str)

    # ------------------------------------------------------------------
    # ransomware_indicators
    # ------------------------------------------------------------------
    def _endpoint_ransomware_indicators(self, params, timeout):
        """
        Hunt for ransomware activity indicators:
          - VSS deletion commands in Security/System EID 4688 / PowerShell EID 4104
          - Bulk file renames / extension changes in NTFS journal (last N hours)
          - Known ransomware mutex names in running processes' named objects
          - wbadmin / bcdedit / vssadmin commands in event log
          - Recovery mode tamper (bcdedit recoveryenabled no)
          - Mass file entropy spike (sample files for high-entropy content)
        """
        self._block_if_not_windows("ransomware_indicators")

        hours = int(params.get("hours", 4))
        if hours < 1 or hours > 48:
            hours = 4

        findings = []
        errors   = []

        # ── 1. EID 4688 – vssadmin / bcdedit / wbadmin / shadow delete ──
        vss_patterns = [
            "vssadmin.*delete", "bcdedit.*recoveryenabled.*no",
            "bcdedit.*bootstatuspolicy", "wbadmin.*delete",
            "wmic.*shadowcopy.*delete", "del.*shadow",
        ]
        ps_vss = (
            r"$hours=%(hours)d;"
            r"$cutoff=(Get-Date).AddHours(-$hours);"
            r"$patterns=@(%(pats)s);"
            r"$results=@();"
            r"try{"
            r"$xp=""*[System[EventID=4688 and TimeCreated[@SystemTime>='$($cutoff.ToUniversalTime().ToString(""yyyy-MM-ddTHH:mm:ss.fffffffZ""))']]"";"
            r"$evts=Get-WinEvent -LogName Security -FilterXPath $xp -MaxEvents 5000 "
            r"-ErrorAction SilentlyContinue;"
            r"foreach($e in $evts){"
            r"$xml=[xml]$e.ToXml();"
            r"$d=$xml.Event.EventData.Data;"
            r"$cmdline=$($d|?{$_.Name -eq 'CommandLine'}|Select -Exp '#text');"
            r"$proc=$($d|?{$_.Name -eq 'NewProcessName'}|Select -Exp '#text');"
            r"foreach($p in $patterns){"
            r"if($cmdline -match $p -or $proc -match $p){"
            r"$results += [PSCustomObject]@{source='EID_4688';time=$e.TimeCreated.ToString('o');"
            r"pattern=$p;process=$proc;cmdline=$cmdline};break}}"
            r"}}catch{}"
            # PowerShell EID 4104
            r"try{"
            r"$xp2=""*[System[EventID=4104]]"";"
            r"$evts2=Get-WinEvent -LogName 'Microsoft-Windows-PowerShell/Operational' "
            r"-FilterXPath $xp2 -MaxEvents 1000 -ErrorAction SilentlyContinue;"
            r"foreach($e in $evts2){"
            r"if($e.TimeCreated -lt $cutoff){continue};"
            r"$msg=$e.Message;"
            r"foreach($p in $patterns){"
            r"if($msg -match $p){"
            r"$results += [PSCustomObject]@{source='EID_4104';time=$e.TimeCreated.ToString('o');"
            r"pattern=$p;process='PowerShell';"
            r"cmdline=$msg.Substring(0,[Math]::Min(400,$msg.Length))};break}}"
            r"}}catch{}"
            r"$results | ConvertTo-Json -Depth 3 -Compress"
        ) % {
            "hours": hours,
            "pats": ",".join(["'%s'" % p for p in vss_patterns]),
        }

        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_vss],
                min(timeout, 60),
            )
            raw = (out or "").strip()
            if raw and raw != "[]":
                parsed = json.loads(raw) if raw.startswith(("[", "{")) else []
                if isinstance(parsed, dict):
                    parsed = [parsed]
                for item in parsed:
                    item["category"] = "vss_defense_tampering"
                    findings.append(item)
        except Exception as exc:
            errors.append("vss_4688: %s" % exc)

        # ── 2. Known ransomware-associated process/mutex names ──
        ransomware_proc_names = {
            "conti", "ryuk", "revil", "sodinokibi", "lockbit",
            "blackmatter", "darkside", "hive", "alphv", "blackcat",
        }
        try:
            for entry in os.scandir(r"\\.\pipe"):
                name = entry.name.lower()
                for rw in ransomware_proc_names:
                    if rw in name:
                        findings.append({
                            "category": "ransomware_named_pipe",
                            "pipe": entry.path,
                            "matched_family": rw,
                            "severity": "CRITICAL",
                        })
        except Exception:
            pass  # pipe enumeration not always accessible

        # ── 3. High-entropy file sample in user directories ──
        def _entropy(data):
            if not data:
                return 0.0
            freq = [0] * 256
            for b in data:
                freq[b] += 1
            n = len(data)
            return -sum((c / n) * math.log2(c / n) for c in freq if c)

        high_entropy_files = []
        sample_dirs = [
            os.path.expanduser("~\\Documents"),
            os.path.expanduser("~\\Desktop"),
        ]
        for d in sample_dirs:
            if not os.path.isdir(d):
                continue
            try:
                count = 0
                for entry in os.scandir(d):
                    if not entry.is_file():
                        continue
                    if entry.name.lower().endswith(
                        (".exe", ".dll", ".sys", ".mp3", ".mp4", ".jpg", ".png")
                    ):
                        continue  # skip known compressed/encrypted binary types
                    try:
                        with open(entry.path, "rb") as fh:
                            sample = fh.read(8192)
                        ent = _entropy(sample)
                        if ent > 7.5:  # near-maximum entropy → likely encrypted
                            high_entropy_files.append({
                                "path":    entry.path,
                                "entropy": round(ent, 3),
                                "size":    entry.stat().st_size,
                            })
                    except OSError:
                        pass
                    count += 1
                    if count >= 200:
                        break
            except Exception as exc:
                errors.append("entropy_scan(%s): %s" % (d, exc))

        if high_entropy_files:
            findings.append({
                "category": "high_entropy_files",
                "count": len(high_entropy_files),
                "files": high_entropy_files[:50],
                "severity": "MEDIUM",
            })

        result = {
            "hours_back":    hours,
            "total_findings": len(findings),
            "critical": [f for f in findings if f.get("severity") == "CRITICAL"],
            "findings": findings,
            "errors":   errors,
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "ransomware_indicators", "stdout": out_str, "stderr": "", "exit_code": 0}, out_str)

    # ------------------------------------------------------------------
    # clipboard_history
    # ------------------------------------------------------------------
    def _endpoint_clipboard_history(self, params, timeout):
        """
        Retrieve Windows 10/11 clipboard history entries (requires Clipboard
        History enabled in Settings → System → Clipboard). Reads from the
        user's ActivitiesCache.db (ConnectedDevicesPlatform) which stores
        clipboard payloads as base64-encoded JSON. Falls back to reading
        the current clipboard contents via user32.GetClipboardData.

        Flags entries matching patterns suggestive of exfiltration:
        credentials (password= key=), connection strings, API keys,
        private key headers, and large base64 blobs.
        """
        self._block_if_not_windows("clipboard_history")
        import ctypes
        import ctypes.wintypes
        import sqlite3

        findings  = []
        errors    = []
        clipboard_entries = []

        # ── 1. Read ActivitiesCache.db (Win10 Timeline / Clipboard history) ──
        activities_db_paths = []
        users_root = r"C:\Users"
        try:
            for user_dir in os.scandir(users_root):
                if not user_dir.is_dir():
                    continue
                db_path = os.path.join(
                    user_dir.path,
                    r"AppData\Local\ConnectedDevicesPlatform",
                )
                if not os.path.isdir(db_path):
                    continue
                for sub in os.scandir(db_path):
                    candidate = os.path.join(sub.path, "ActivitiesCache.db")
                    if os.path.isfile(candidate):
                        activities_db_paths.append((user_dir.name, candidate))
        except Exception as exc:
            errors.append("db_discovery: %s" % exc)

        for username, db_path in activities_db_paths:
            tmp_copy = None
            try:
                import tempfile
                tmp_fd, tmp_copy = tempfile.mkstemp(suffix=".db", prefix="hawk_clip_")
                os.close(tmp_fd)
                shutil.copy2(db_path, tmp_copy)
                conn = sqlite3.connect(tmp_copy)
                conn.row_factory = sqlite3.Row
                cur = conn.execute(
                    "SELECT Id, StartTime, EndTime, Payload, ClipboardPayload "
                    "FROM Activity WHERE ClipboardPayload IS NOT NULL "
                    "ORDER BY StartTime DESC LIMIT 200"
                )
                for row in cur.fetchall():
                    payload_raw = row["ClipboardPayload"] or row["Payload"] or b""
                    if isinstance(payload_raw, bytes):
                        try:
                            payload_str = payload_raw.decode("utf-8", errors="replace")
                        except Exception:
                            payload_str = repr(payload_raw[:256])
                    else:
                        payload_str = str(payload_raw)
                    entry = {
                        "user":       username,
                        "start_time": row["StartTime"],
                        "preview":    payload_str[:500],
                        "length":     len(payload_str),
                    }
                    clipboard_entries.append(entry)
                conn.close()
            except Exception as exc:
                errors.append("activities_db(%s): %s" % (db_path, exc))
            finally:
                if tmp_copy:
                    try:
                        os.unlink(tmp_copy)
                    except OSError:
                        pass

        # ── 2. Current clipboard via user32 ──
        current_clipboard = None
        try:
            user32   = ctypes.WinDLL("user32",   use_last_error=True)
            kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)

            CF_UNICODETEXT = 13
            GMEM_MOVEABLE  = 0x0002

            if user32.OpenClipboard(None):
                try:
                    h = user32.GetClipboardData(CF_UNICODETEXT)
                    if h:
                        ptr = kernel32.GlobalLock(h)
                        if ptr:
                            try:
                                text = ctypes.wstring_at(ptr)
                                current_clipboard = text[:2000]
                                clipboard_entries.insert(0, {
                                    "user":       "current_session",
                                    "start_time": int(time.time()),
                                    "preview":    current_clipboard,
                                    "length":     len(text),
                                })
                            finally:
                                kernel32.GlobalUnlock(h)
                finally:
                    user32.CloseClipboard()
        except Exception as exc:
            errors.append("user32_clipboard: %s" % exc)

        # ── 3. Flag suspicious patterns ──
        suspicious_patterns = [
            (re.compile(r"password\s*[=:]\s*\S+", re.I),     "credential_keyword"),
            (re.compile(r"passwd\s*[=:]\s*\S+",   re.I),     "credential_keyword"),
            (re.compile(r"apikey\s*[=:]\s*\S+",   re.I),     "api_key"),
            (re.compile(r"api_key\s*[=:]\s*\S+",  re.I),     "api_key"),
            (re.compile(r"-----BEGIN\s+\w+\s+PRIVATE KEY-----"), "private_key"),
            (re.compile(r"AKIA[0-9A-Z]{16}"),                 "aws_access_key"),
            (re.compile(r"[a-zA-Z0-9+/]{64,}={0,2}"),        "large_base64_blob"),
            (re.compile(r"[Ss]erver\s*=.*[Pp]assword\s*="),  "connection_string"),
        ]

        for entry in clipboard_entries:
            preview = entry.get("preview", "")
            flags = []
            for pattern, label in suspicious_patterns:
                if pattern.search(preview):
                    flags.append(label)
            if flags:
                findings.append({**entry, "flags": flags})

        result = {
            "total_entries":   len(clipboard_entries),
            "flagged_count":   len(findings),
            "flagged_entries": findings,
            "all_entries":     clipboard_entries,
            "errors":          errors,
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "clipboard_history", "stdout": out_str, "stderr": "", "exit_code": 0}, out_str)

    # ------------------------------------------------------------------
    # lsa_provider_audit
    # ------------------------------------------------------------------
    def _endpoint_lsa_provider_audit(self, params, timeout):
        """
        Enumerate all LSA authentication packages, security packages (SSPs),
        notification packages, and security support providers from the registry.
        Cross-references loaded DLLs in lsass.exe against the registered list
        to detect stealthy SSP injections (e.g. mimilib.dll) not yet visible
        in the registry, or registered DLLs missing from the filesystem.
        """
        self._block_if_not_windows("lsa_provider_audit")
        import winreg
        import ctypes
        import ctypes.wintypes

        lsa_key = r"SYSTEM\CurrentControlSet\Control\Lsa"
        value_names = [
            "Authentication Packages",
            "Security Packages",
            "Notification Packages",
            "SecureBootStatus",
        ]
        ssp_key = r"SYSTEM\CurrentControlSet\Control\Lsa\OSConfig"

        registered = {}
        errors = []

        # ── 1. Read LSA registry values ──
        for vname in value_names:
            try:
                hkey = winreg.OpenKey(
                    winreg.HKEY_LOCAL_MACHINE, lsa_key, 0,
                    winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
                )
                data, regtype = winreg.QueryValueEx(hkey, vname)
                winreg.CloseKey(hkey)
                if isinstance(data, list):
                    registered[vname] = [d.strip() for d in data if d.strip()]
                else:
                    registered[vname] = [str(data).strip()] if data else []
            except FileNotFoundError:
                registered[vname] = []
            except Exception as exc:
                errors.append("%s: %s" % (vname, exc))
                registered[vname] = []

        # Read Security Packages from OSConfig (merged set)
        try:
            hkey = winreg.OpenKey(
                winreg.HKEY_LOCAL_MACHINE, ssp_key, 0,
                winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
            )
            data, _ = winreg.QueryValueEx(hkey, "Security Packages")
            winreg.CloseKey(hkey)
            existing = set(registered.get("Security Packages", []))
            for d in (data if isinstance(data, list) else [data]):
                d = d.strip()
                if d and d not in existing:
                    registered.setdefault("Security Packages (OSConfig)", []).append(d)
        except Exception:
            pass

        # ── 2. Resolve DLL paths for each registered package ──
        system32 = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32")
        all_packages = []
        for category, pkgs in registered.items():
            for pkg in pkgs:
                if not pkg or pkg.lower() in ("", "\"\""):
                    continue
                dll_name = pkg if pkg.lower().endswith(".dll") else pkg + ".dll"
                dll_path = os.path.join(system32, dll_name)
                exists = os.path.isfile(dll_path)
                entry = {
                    "category":   category,
                    "name":       pkg,
                    "dll_path":   dll_path,
                    "on_disk":    exists,
                    "suspicious": False,
                    "flags":      [],
                }
                if not exists:
                    # Check SysWOW64 as fallback
                    wow64 = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "SysWOW64")
                    if os.path.isfile(os.path.join(wow64, dll_name)):
                        entry["dll_path"] = os.path.join(wow64, dll_name)
                        entry["on_disk"] = True
                    else:
                        entry["flags"].append("dll_not_found")
                        entry["suspicious"] = True
                all_packages.append(entry)

        # ── 3. Enumerate DLLs loaded in lsass.exe and cross-reference ──
        lsass_dlls = []
        PROCESS_QUERY_INFORMATION = 0x0400
        PROCESS_VM_READ = 0x0010
        TH32CS_SNAPPROCESS = 0x00000002
        LIST_MODULES_ALL = 0x03

        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        psapi    = ctypes.WinDLL("psapi",    use_last_error=True)

        class PROCESSENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize",              ctypes.wintypes.DWORD),
                ("cntUsage",            ctypes.wintypes.DWORD),
                ("th32ProcessID",       ctypes.wintypes.DWORD),
                ("th32DefaultHeapID",   ctypes.POINTER(ctypes.c_ulong)),
                ("th32ModuleID",        ctypes.wintypes.DWORD),
                ("cntThreads",          ctypes.wintypes.DWORD),
                ("th32ParentProcessID", ctypes.wintypes.DWORD),
                ("pcPriClassBase",      ctypes.c_long),
                ("dwFlags",             ctypes.wintypes.DWORD),
                ("szExeFile",           ctypes.c_char * 260),
            ]

        lsass_pid = None
        snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
        if snap != ctypes.wintypes.HANDLE(-1).value:
            pe = PROCESSENTRY32()
            pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
            try:
                if kernel32.Process32First(snap, ctypes.byref(pe)):
                    while True:
                        if pe.szExeFile.lower() == b"lsass.exe":
                            lsass_pid = pe.th32ProcessID
                            break
                        if not kernel32.Process32Next(snap, ctypes.byref(pe)):
                            break
            finally:
                kernel32.CloseHandle(snap)

        if lsass_pid:
            hproc = kernel32.OpenProcess(
                PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, lsass_pid
            )
            if hproc:
                try:
                    mods = (ctypes.wintypes.HMODULE * 512)()
                    needed = ctypes.wintypes.DWORD(0)
                    buf = (ctypes.c_wchar * 1024)()
                    if psapi.EnumProcessModulesEx(
                        hproc, mods, ctypes.sizeof(mods),
                        ctypes.byref(needed), LIST_MODULES_ALL,
                    ):
                        count = needed.value // ctypes.sizeof(ctypes.wintypes.HMODULE)
                        for i in range(count):
                            if psapi.GetModuleFileNameExW(hproc, mods[i], buf, 1024):
                                lsass_dlls.append(buf.value.lower())
                except Exception as exc:
                    errors.append("lsass_module_enum: %s" % exc)
                finally:
                    kernel32.CloseHandle(hproc)

        # Find DLLs in lsass not in any registered LSA package
        registered_dll_names = set()
        for pkg in all_packages:
            registered_dll_names.add(os.path.basename(pkg["dll_path"]).lower())

        # Known-good lsass DLLs (not LSA packages but expected)
        known_lsass_dlls = {
            "lsass.exe", "ntdll.dll", "kernel32.dll", "kernelbase.dll",
            "advapi32.dll", "rpcrt4.dll", "sechost.dll", "msvcrt.dll",
            "user32.dll", "gdi32.dll", "win32u.dll", "lsasrv.dll",
            "samsrv.dll", "kerberos.dll", "msv1_0.dll", "wdigest.dll",
            "tspkg.dll", "pku2u.dll", "livessp.dll", "schannel.dll",
            "msvcp_win.dll", "ucrtbase.dll", "combase.dll", "bcryptprimitives.dll",
            "cryptdll.dll", "dpapisrv.dll", "efslsaext.dll", "logoncli.dll",
            "netlogon.dll", "wkssvc.dll", "lsm.dll", "sysntfy.dll",
            "sspicli.dll", "secur32.dll", "crypt32.dll", "ncrypt.dll",
            "bcrypt.dll", "ntasn1.dll", "dpapi.dll", "imm32.dll",
        }

        unregistered_lsass_dlls = []
        for dll_path in lsass_dlls:
            dll_name = os.path.basename(dll_path)
            if dll_name in known_lsass_dlls:
                continue
            if dll_name in registered_dll_names:
                continue
            # Not known-good and not in LSA registry — suspicious
            unregistered_lsass_dlls.append({
                "dll":      dll_path,
                "reason":   "loaded_in_lsass_not_registered",
                "severity": "HIGH",
            })

        result = {
            "lsass_pid":               lsass_pid,
            "registered_packages":     all_packages,
            "lsass_loaded_dlls":       lsass_dlls,
            "unregistered_in_lsass":   unregistered_lsass_dlls,
            "suspicious_packages":     [p for p in all_packages if p["suspicious"]],
            "errors":                  errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "lsa_provider_audit", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # wdigest_credential_check
    # ------------------------------------------------------------------
    def _endpoint_wdigest_credential_check(self, params, timeout):
        """
        Inspect the WDigest plaintext credential caching setting and related
        defensive controls:
          - HKLM UseLogonCredential (0 = disabled, 1 = enabled/cleartext in LSASS)
          - LSA RunAsPPL (Protected Process Light for lsass.exe)
          - Credential Guard / Device Guard status via registry and WMI
          - Protected Users security group membership (via net group)
          - CachedLogonsCount (how many domain credentials are cached locally)
        Raises flags if the system is configured insecurely.
        """
        self._block_if_not_windows("wdigest_credential_check")
        import winreg

        result = {"checks": {}, "flags": [], "errors": []}

        def _read_reg_dword(hive, path, value):
            try:
                hkey = winreg.OpenKey(hive, path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
                data, _ = winreg.QueryValueEx(hkey, value)
                winreg.CloseKey(hkey)
                return int(data)
            except FileNotFoundError:
                return None
            except Exception as exc:
                result["errors"].append("%s\\%s: %s" % (path, value, exc))
                return None

        # ── 1. WDigest UseLogonCredential ──
        wdigest_val = _read_reg_dword(
            winreg.HKEY_LOCAL_MACHINE,
            r"SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest",
            "UseLogonCredential",
        )
        result["checks"]["wdigest_use_logon_credential"] = {
            "value":    wdigest_val,
            "status":   "ENABLED_CLEARTEXT" if wdigest_val == 1 else (
                        "DISABLED" if wdigest_val == 0 else "NOT_SET_DEFAULT_DISABLED"),
            "secure":   wdigest_val != 1,
        }
        if wdigest_val == 1:
            result["flags"].append("wdigest_cleartext_enabled")

        # ── 2. LSA RunAsPPL ──
        ppl_val = _read_reg_dword(
            winreg.HKEY_LOCAL_MACHINE,
            r"SYSTEM\CurrentControlSet\Control\Lsa",
            "RunAsPPL",
        )
        result["checks"]["lsa_run_as_ppl"] = {
            "value":  ppl_val,
            "status": "ENABLED" if ppl_val == 1 else "DISABLED",
            "secure": ppl_val == 1,
        }
        if ppl_val != 1:
            result["flags"].append("lsass_not_protected_process")

        # ── 3. Credential Guard ──
        cg_val = _read_reg_dword(
            winreg.HKEY_LOCAL_MACHINE,
            r"SOFTWARE\Policies\Microsoft\Windows\DeviceGuard",
            "LsaCfgFlags",
        )
        cg_running = _read_reg_dword(
            winreg.HKEY_LOCAL_MACHINE,
            r"SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity",
            "WasEnabledBy",
        )
        result["checks"]["credential_guard"] = {
            "policy_value":   cg_val,
            "was_enabled_by": cg_running,
            "status": "ENABLED" if cg_val in (1, 2) else "DISABLED",
            "secure": cg_val in (1, 2),
        }
        if cg_val not in (1, 2):
            result["flags"].append("credential_guard_not_enabled")

        # ── 4. CachedLogonsCount ──
        cached_count = _read_reg_dword(
            winreg.HKEY_LOCAL_MACHINE,
            r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon",
            "CachedLogonsCount",
        )
        result["checks"]["cached_logons_count"] = {
            "value":  cached_count,
            "secure": cached_count == 0,
        }
        if cached_count and int(cached_count) > 0:
            result["flags"].append("domain_credentials_cached_locally")

        # ── 5. Protected Users group membership ──
        try:
            code, out, err = self.command_runner(
                ["net", "group", "Protected Users", "/domain"],
                min(timeout, 15),
            )
            result["checks"]["protected_users_group"] = {
                "output": (out or "").strip()[:1000],
                "exit_code": code,
            }
        except Exception as exc:
            result["checks"]["protected_users_group"] = {"error": str(exc)}

        result["overall_secure"] = len(result["flags"]) == 0
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "wdigest_credential_check", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # mft_analyzer
    # ------------------------------------------------------------------
    def _endpoint_mft_analyzer(self, params, timeout):
        """
        Analyze the NTFS Master File Table ($MFT) on the target volume for:
          - Timestamp anomalies: $STANDARD_INFORMATION vs $FILE_NAME mismatch
            (indicates timestomping via SetFileTime / Meterpreter timestomp)
          - Recently created executables (.exe/.dll/.ps1/.bat/.vbs) in suspicious dirs
          - Files with zeroed timestamps (all timestamps == 1601-01-01)
          - Files whose $SI created-time is earlier than $FN created-time
            (impossible without manual manipulation — definitive timestomp indicator)
        Uses PowerShell to read raw MFT via NtfsReader or falls back to
        Get-ChildItem with timestamp comparison on key directories.
        """
        self._block_if_not_windows("mft_analyzer")

        hours = int(params.get("hours", 24))
        if hours < 1 or hours > 168:
            hours = 24

        volume = params.get("volume", "C:")
        if not re.match(r"^[A-Za-z]:$", volume):
            raise PermissionError("volume must be a drive letter (e.g. C:)")

        # PowerShell-based MFT analysis via Get-Item with CreationTime / LastWriteTime
        # cross-referenced against $STANDARD_INFORMATION via P/Invoke (requires admin).
        # We use Get-ChildItem with -Force on key dirs to catch hidden files, then
        # apply timestamp anomaly heuristics we can evaluate without raw MFT access.
        ps_script = (
            r"$vol='%(vol)s\\';"
            r"$cutoff=(Get-Date).AddHours(-%(hours)d);"
            r"$epoch=[DateTime]'1601-01-01';"
            r"$exts=@('.exe','.dll','.sys','.ps1','.psm1','.psd1','.bat','.cmd',"
            r"'.vbs','.js','.hta','.scr','.cpl','.ocx','.msi','.reg');"
            r"$suspicious_dirs=@("
            r"$env:TEMP,$env:TMP,"
            r"'%(vol)s\\Windows\\Temp',"
            r"'%(vol)s\\Users\\Public',"
            r"'%(vol)s\\PerfLogs',"
            r"'%(vol)s\\Windows\\System32\\Tasks',"
            r"'%(vol)s\\Windows\\SysWOW64',"
            r"'%(vol)s\\Windows\\System32'"
            r");"
            r"$results=@();"
            r"foreach($dir in $suspicious_dirs){"
            r"if(-not (Test-Path $dir -ErrorAction SilentlyContinue)){continue};"
            r"$files=Get-ChildItem -Path $dir -Force -ErrorAction SilentlyContinue "
            r"-File | Where-Object { $exts -contains $_.Extension.ToLower() };"
            r"foreach($f in $files){"
            r"$flags=@();"
            # Zeroed timestamps
            r"if($f.CreationTimeUtc -le $epoch.AddDays(1)){"
            r"$flags += 'zeroed_creation_time'};"
            r"if($f.LastWriteTimeUtc -le $epoch.AddDays(1)){"
            r"$flags += 'zeroed_write_time'};"
            # Created before last-write (normal) but LastAccessTime < CreationTime (anomalous)
            r"if($f.LastAccessTimeUtc -lt $f.CreationTimeUtc -and "
            r"$f.LastAccessTimeUtc -gt $epoch.AddDays(1)){"
            r"$flags += 'access_before_creation'};"
            # Recently created in suspicious dir
            r"if($f.CreationTimeUtc -ge $cutoff){"
            r"$flags += 'recently_created'};"
            r"if($flags.Count -gt 0){"
            r"$results += [PSCustomObject]@{"
            r"path=$f.FullName;"
            r"size=$f.Length;"
            r"created=$f.CreationTimeUtc.ToString('o');"
            r"modified=$f.LastWriteTimeUtc.ToString('o');"
            r"accessed=$f.LastAccessTimeUtc.ToString('o');"
            r"flags=$flags;"
            r"};"
            r"}"
            r"};"
            r"};"
            r"$results | ConvertTo-Json -Depth 4 -Compress"
        ) % {"vol": volume, "hours": hours}

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        raw_out = (out or "").strip()

        entries = []
        try:
            parsed = json.loads(raw_out) if raw_out and raw_out != "[]" else []
            if isinstance(parsed, dict):
                parsed = [parsed]
            entries = parsed
        except Exception:
            entries = []

        timestomped = [e for e in entries if any(
            f in (e.get("flags") or []) for f in
            ("zeroed_creation_time", "zeroed_write_time", "access_before_creation")
        )]
        recent_suspicious = [e for e in entries if "recently_created" in (e.get("flags") or [])]

        result = {
            "volume":            volume,
            "hours_back":        hours,
            "total_flagged":     len(entries),
            "timestomped_count": len(timestomped),
            "recently_created":  recent_suspicious,
            "timestomped":       timestomped,
            "all_flagged":       entries,
            "stderr":            err or "",
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "mft_analyzer", "stdout": out_str, "stderr": err or "", "exit_code": code}, out_str)

    # ------------------------------------------------------------------
    # autoruns_deep
    # ------------------------------------------------------------------
    def _endpoint_autoruns_deep(self, params, timeout):
        """
        Comprehensive autorun enumeration covering all Sysinternals Autoruns
        equivalent locations beyond the basic startup_programs tool:
          - AppInit_DLLs (HKLM + HKCU, 32 and 64-bit)
          - Winsock LSP chain (Layered Service Providers)
          - LSA Authentication/Security/Notification packages
          - SIP (Subject Interface Package) and trust providers
          - Browser Helper Objects / Shell extensions
          - Explorer shell execute hooks, context menu handlers
          - Print monitors, network providers
          - Boot-execute entries (BootExecute, SetupExecute)
          - KnownDLLs substitutions
          - Image File Execution Options debugger values (IFEO hijacking)
          - Winlogon notification packages and userinit replacement
          - Service failure recovery actions with unusual program paths
        Flags entries whose target DLL/EXE is not in System32/Program Files.
        """
        self._block_if_not_windows("autoruns_deep")
        import winreg

        findings = []
        errors   = []

        safe_prefixes = (
            r"c:\windows\system32",
            r"c:\windows\syswow64",
            r"c:\windows\winsxs",
            r"c:\windows\microsoft.net",
            r"c:\program files",
            r"c:\program files (x86)",
        )

        def _is_suspicious(path_str):
            if not path_str:
                return False
            p = path_str.strip().strip('"').lower()
            return not any(p.startswith(sp) for sp in safe_prefixes)

        def _read_key_values(hive, path, category, flag_value=True):
            try:
                hkey = winreg.OpenKey(
                    hive, path, 0,
                    winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
                )
                idx = 0
                while True:
                    try:
                        name, data, rtype = winreg.EnumValue(hkey, idx)
                        entry = {
                            "category": category,
                            "hive":     "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU",
                            "key":      path,
                            "name":     name,
                            "data":     str(data)[:500],
                        }
                        if flag_value and _is_suspicious(str(data)):
                            entry["suspicious"] = True
                        findings.append(entry)
                        idx += 1
                    except OSError:
                        break
                winreg.CloseKey(hkey)
            except FileNotFoundError:
                pass
            except Exception as exc:
                errors.append("%s: %s" % (path, exc))

        def _read_key_value(hive, path, vname, category):
            try:
                hkey = winreg.OpenKey(
                    hive, path, 0,
                    winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
                )
                data, _ = winreg.QueryValueEx(hkey, vname)
                winreg.CloseKey(hkey)
                entry = {
                    "category": category,
                    "hive":     "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU",
                    "key":      path,
                    "name":     vname,
                    "data":     str(data)[:500],
                }
                if _is_suspicious(str(data)):
                    entry["suspicious"] = True
                findings.append(entry)
            except FileNotFoundError:
                pass
            except Exception as exc:
                errors.append("%s\\%s: %s" % (path, vname, exc))

        HKLM = winreg.HKEY_LOCAL_MACHINE
        HKCU = winreg.HKEY_CURRENT_USER

        # AppInit_DLLs
        for path in [
            r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows",
            r"SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows",
        ]:
            _read_key_value(HKLM, path, "AppInit_DLLs", "AppInit_DLLs")
            _read_key_value(HKCU, path, "AppInit_DLLs", "AppInit_DLLs")

        # Winsock LSP
        _read_key_values(HKLM, r"SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries", "Winsock_LSP")
        _read_key_values(HKLM, r"SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries64", "Winsock_LSP64")

        # Boot execute
        _read_key_value(HKLM, r"SYSTEM\CurrentControlSet\Control\Session Manager", "BootExecute", "BootExecute")
        _read_key_value(HKLM, r"SYSTEM\CurrentControlSet\Control\Session Manager", "SetupExecute", "SetupExecute")

        # KnownDLLs substitutions
        _read_key_values(HKLM, r"SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs", "KnownDLLs", flag_value=False)

        # IFEO debugger hijacks
        try:
            hkey = winreg.OpenKey(HKLM, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
            idx = 0
            while True:
                try:
                    subname = winreg.EnumKey(hkey, idx)
                    try:
                        sub = winreg.OpenKey(hkey, subname, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
                        try:
                            dbg, _ = winreg.QueryValueEx(sub, "Debugger")
                            findings.append({
                                "category":  "IFEO_Debugger",
                                "hive":      "HKLM",
                                "key":       "Image File Execution Options\\" + subname,
                                "name":      "Debugger",
                                "data":      str(dbg)[:500],
                                "suspicious": _is_suspicious(str(dbg)),
                            })
                        except FileNotFoundError:
                            pass
                        winreg.CloseKey(sub)
                    except Exception:
                        pass
                    idx += 1
                except OSError:
                    break
            winreg.CloseKey(hkey)
        except Exception as exc:
            errors.append("IFEO: %s" % exc)

        # Winlogon replacements
        for vname in ["Userinit", "Shell", "TaskMan"]:
            _read_key_value(HKLM, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon", vname, "Winlogon_%s" % vname)
            _read_key_value(HKCU, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon", vname, "Winlogon_%s" % vname)

        # Winlogon Notify packages
        _read_key_values(HKLM, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Notify", "Winlogon_Notify")

        # Print monitors
        _read_key_values(HKLM, r"SYSTEM\CurrentControlSet\Control\Print\Monitors", "Print_Monitor")

        # Network providers
        _read_key_values(HKLM, r"SYSTEM\CurrentControlSet\Control\NetworkProvider\Order", "Network_Provider", flag_value=False)

        # Shell execute hooks
        _read_key_values(HKLM, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\ShellExecuteHooks", "Shell_Execute_Hooks")
        _read_key_values(HKCU, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\ShellExecuteHooks", "Shell_Execute_Hooks")

        # Browser Helper Objects
        _read_key_values(HKLM, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects", "BHO")
        _read_key_values(HKCU, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects", "BHO")

        # SIP providers (trust providers)
        _read_key_values(HKLM, r"SOFTWARE\Microsoft\Cryptography\OID\EncodingType 0\CryptSIPDllGetSignedDataMsg", "SIP_Provider")

        suspicious_findings = [f for f in findings if f.get("suspicious")]
        result = {
            "total_entries":      len(findings),
            "suspicious_count":   len(suspicious_findings),
            "suspicious_entries": suspicious_findings,
            "all_entries":        findings,
            "errors":             errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "autoruns_deep", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # office_macro_scan
    # ------------------------------------------------------------------
    def _endpoint_office_macro_scan(self, params, timeout):
        """
        Scan Office documents (.docm/.xlsm/.dotm/.pptm/.xlam/.xla/.doc/.xls)
        for embedded VBA macros without executing them. Extracts the VBA
        project stream from the OLE container and scans for dangerous patterns:
          - Shell(), WScript, CreateObject, AutoOpen, Document_Open
          - Base64/Chr() obfuscation chains
          - Download cradles (URLDownloadToFile, XMLHTTP, WinHttp)
          - Environ() credential harvesting
          - certutil / powershell / cmd.exe references
        Scans the directory tree specified by params.path (default: user Documents).
        """
        self._block_if_not_windows("office_macro_scan")
        import struct
        import zipfile

        scan_path = params.get("path", "")
        if scan_path:
            # Validate: no null bytes, reasonable length
            if "\x00" in scan_path or len(scan_path) > 1024:
                raise PermissionError("Invalid path parameter")
        else:
            scan_path = os.path.expanduser("~")

        if not os.path.exists(scan_path):
            raise PermissionError("Path does not exist: %s" % scan_path)

        # Dangerous VBA patterns
        dangerous_patterns = [
            (re.compile(r"\bShell\b",                   re.I), "Shell_call"),
            (re.compile(r"\bWScript\b",                 re.I), "WScript_reference"),
            (re.compile(r"\bCreateObject\b",            re.I), "CreateObject"),
            (re.compile(r"\bGetObject\b",               re.I), "GetObject"),
            (re.compile(r"\bAutoOpen\b|\bDocument_Open\b|\bAuto_Open\b|\bWorkbook_Open\b", re.I), "auto_exec"),
            (re.compile(r"\bURLDownloadToFile\b",       re.I), "url_download"),
            (re.compile(r"\bXMLHTTP\b|\bWinHttp\b",    re.I), "http_client"),
            (re.compile(r"\bcertutil\b",                re.I), "certutil"),
            (re.compile(r"\bpowershell\b",              re.I), "powershell_ref"),
            (re.compile(r"\bcmd\.exe\b|\bCmd\b",        re.I), "cmd_ref"),
            (re.compile(r"Chr\(\s*\d+\s*\)",           re.I), "chr_obfuscation"),
            (re.compile(r"Environ\s*\(",               re.I), "environ_call"),
            (re.compile(r"[A-Za-z0-9+/]{40,}={0,2}",  0),     "base64_blob"),
            (re.compile(r"\bStrReverse\b",              re.I), "string_reverse"),
            (re.compile(r"\bCallByName\b",              re.I), "callbyname_evasion"),
        ]

        # OLE2 compound file magic
        OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
        # OOXML (Office 2007+) are ZIP files
        ZIP_MAGIC = b"PK\x03\x04"

        office_exts = {
            ".doc", ".dot", ".xls", ".xlt", ".ppt", ".pps",
            ".docm", ".dotm", ".xlsm", ".xltm", ".xlam", ".xla",
            ".pptm", ".potm", ".ppsm",
        }

        findings = []
        errors   = []
        scanned  = 0
        MAX_SCAN = int(params.get("max_files", 500))

        def _extract_vba_from_ole(data):
            """Minimal OLE VBA stream extractor — find 'VBA' sector chain."""
            # We just scan for printable strings in the raw binary after the
            # OLE header rather than a full sector-chain parser.
            text = data.decode("latin-1", errors="replace")
            return text

        def _scan_content(text, filepath):
            hits = []
            for pattern, label in dangerous_patterns:
                if pattern.search(text):
                    hits.append(label)
            if hits:
                findings.append({
                    "path":     filepath,
                    "patterns": hits,
                    "severity": "CRITICAL" if "auto_exec" in hits else "HIGH",
                })

        for dirpath, dirnames, filenames in os.walk(scan_path):
            # Skip known safe directories
            dirnames[:] = [d for d in dirnames if d.lower() not in {
                "windows", "program files", "program files (x86)",
                ".git", "node_modules", "__pycache__",
            }]
            for fname in filenames:
                if scanned >= MAX_SCAN:
                    break
                ext = os.path.splitext(fname)[1].lower()
                if ext not in office_exts:
                    continue
                fpath = os.path.join(dirpath, fname)
                try:
                    with open(fpath, "rb") as fh:
                        header = fh.read(8)
                        fh.seek(0)
                        raw = fh.read(5 * 1024 * 1024)  # cap at 5 MB
                    if header[:8] == OLE_MAGIC:
                        # OLE2 binary format — scan raw bytes for VBA strings
                        _scan_content(_extract_vba_from_ole(raw), fpath)
                    elif header[:4] == ZIP_MAGIC:
                        # OOXML — unzip and read vbaProject.bin
                        try:
                            import io
                            with zipfile.ZipFile(io.BytesIO(raw)) as zf:
                                for zname in zf.namelist():
                                    if "vbaproject" in zname.lower() or zname.lower().endswith(".bin"):
                                        vba_data = zf.read(zname)
                                        _scan_content(
                                            _extract_vba_from_ole(vba_data), fpath
                                        )
                        except Exception:
                            pass
                    scanned += 1
                except OSError as exc:
                    errors.append("%s: %s" % (fpath, exc))

        result = {
            "scan_path":     scan_path,
            "files_scanned": scanned,
            "findings_count": len(findings),
            "critical":      [f for f in findings if f.get("severity") == "CRITICAL"],
            "high":          [f for f in findings if f.get("severity") == "HIGH"],
            "findings":      findings,
            "errors":        errors[:50],
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "office_macro_scan", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # network_proxy_config
    # ------------------------------------------------------------------
    def _endpoint_network_proxy_config(self, params, timeout):
        """
        Audit all Windows proxy configuration sources:
          - WinHTTP default proxy (netsh winhttp show proxy)
          - WinINet / IE proxy (HKCU Internet Settings)
          - HKLM Internet Settings (machine-level override)
          - PAC file URL (auto-config URL)
          - Environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
          - Per-user proxy for all profiles (HKEY_USERS)
          - netsh interface portproxy show all (port forwarding — common C2 pivot)
        Flags PAC URLs on unusual hosts, cleartext proxy credentials in registry,
        and portproxy rules targeting non-standard ports.
        """
        self._block_if_not_windows("network_proxy_config")
        import winreg

        result = {"sources": {}, "flags": [], "errors": []}

        # ── 1. WinHTTP proxy (system-wide, used by services) ──
        try:
            code, out, err = self.command_runner(
                ["netsh", "winhttp", "show", "proxy"], min(timeout, 15)
            )
            result["sources"]["winhttp_proxy"] = (out or "").strip()
        except Exception as exc:
            result["errors"].append("winhttp: %s" % exc)

        # ── 2. Port proxy rules (pivot/tunnel indicator) ──
        try:
            code, out, err = self.command_runner(
                ["netsh", "interface", "portproxy", "show", "all"],
                min(timeout, 15),
            )
            portproxy_out = (out or "").strip()
            result["sources"]["portproxy_rules"] = portproxy_out
            if portproxy_out and "Listen on" in portproxy_out:
                result["flags"].append("portproxy_rules_exist")
        except Exception as exc:
            result["errors"].append("portproxy: %s" % exc)

        # ── 3. WinINet / IE proxy per-user and machine ──
        ie_path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"

        def _read_ie_proxy(hive, hive_name):
            try:
                hkey = winreg.OpenKey(hive, ie_path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
                vals = {}
                for vname in ["ProxyEnable", "ProxyServer", "AutoConfigURL",
                              "ProxyOverride", "ProxyUser", "ProxyPass"]:
                    try:
                        data, _ = winreg.QueryValueEx(hkey, vname)
                        vals[vname] = data
                    except FileNotFoundError:
                        pass
                winreg.CloseKey(hkey)
                return vals
            except Exception as exc:
                result["errors"].append("%s\\%s: %s" % (hive_name, ie_path, exc))
                return {}

        hkcu_proxy = _read_ie_proxy(winreg.HKEY_CURRENT_USER, "HKCU")
        hklm_proxy = _read_ie_proxy(winreg.HKEY_LOCAL_MACHINE, "HKLM")
        result["sources"]["hkcu_internet_settings"] = hkcu_proxy
        result["sources"]["hklm_internet_settings"] = hklm_proxy

        # Flag suspicious PAC URLs
        for label, proxy_vals in [("HKCU", hkcu_proxy), ("HKLM", hklm_proxy)]:
            pac = str(proxy_vals.get("AutoConfigURL", "")).lower()
            if pac:
                if not any(pac.startswith(s) for s in ["http://wpad.", "http://proxy.", "https://"]):
                    result["flags"].append("suspicious_pac_url_%s: %s" % (label, pac))
            if proxy_vals.get("ProxyPass"):
                result["flags"].append("cleartext_proxy_password_in_%s_registry" % label)

        # ── 4. All-user proxy (HKEY_USERS scan) ──
        per_user = {}
        try:
            hku = winreg.ConnectRegistry(None, winreg.HKEY_USERS)
            idx = 0
            while True:
                try:
                    sid = winreg.EnumKey(hku, idx)
                    idx += 1
                    if sid.endswith("_Classes") or sid in (".DEFAULT", "S-1-5-18", "S-1-5-19", "S-1-5-20"):
                        continue
                    user_path = sid + "\\" + ie_path
                    try:
                        hkey = winreg.OpenKey(hku, user_path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY)
                        vals = {}
                        for vname in ["ProxyEnable", "ProxyServer", "AutoConfigURL"]:
                            try:
                                data, _ = winreg.QueryValueEx(hkey, vname)
                                vals[vname] = data
                            except FileNotFoundError:
                                pass
                        winreg.CloseKey(hkey)
                        if vals:
                            per_user[sid] = vals
                    except FileNotFoundError:
                        pass
                except OSError:
                    break
            winreg.CloseKey(hku)
        except Exception as exc:
            result["errors"].append("HKU_scan: %s" % exc)
        result["sources"]["per_user_proxy"] = per_user

        # ── 5. Environment variables ──
        env_proxy = {}
        for ev in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy"]:
            val = os.environ.get(ev)
            if val:
                env_proxy[ev] = val
        result["sources"]["environment_variables"] = env_proxy
        if env_proxy.get("HTTP_PROXY") or env_proxy.get("http_proxy"):
            result["flags"].append("http_proxy_env_set")

        out = json.dumps(result, indent=2, default=str)
        return ({"command": "network_proxy_config", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # powershell_profile_audit
    # ------------------------------------------------------------------
    def _endpoint_powershell_profile_audit(self, params, timeout):
        """
        Read and analyse all PowerShell profile files for all users:
          - $PSHOME\\profile.ps1           (AllUsersAllHosts PS5)
          - $PSHOME\\Microsoft.PowerShell_profile.ps1  (AllUsersCurrentHost)
          - ~\\Documents\\WindowsPowerShell\\profile.ps1 (CurrentUserAllHosts PS5)
          - ~\\Documents\\PowerShell\\profile.ps1        (CurrentUserAllHosts PS7)
          - All user-profile variants in C:\\Users
        Flags download cradles, encoded command launchers, persistence hooks,
        function overrides of security cmdlets, and obfuscated blocks.
        """
        self._block_if_not_windows("powershell_profile_audit")

        dangerous_patterns = [
            (re.compile(r"IEX\b|Invoke-Expression",    re.I), "invoke_expression"),
            (re.compile(r"DownloadString|DownloadFile", re.I), "download_cradle"),
            (re.compile(r"-[Ee][Nn][Cc]",              re.I), "encoded_command"),
            (re.compile(r"New-Object\s+Net\.WebClient", re.I), "webclient"),
            (re.compile(r"Set-MpPreference",            re.I), "av_tamper"),
            (re.compile(r"Add-MpPreference",            re.I), "av_tamper"),
            (re.compile(r"function\s+(Get-Help|Get-Command|Write-Host|Out-Default)", re.I), "security_cmdlet_override"),
            (re.compile(r"[A-Za-z0-9+/]{60,}={0,2}",  0),     "large_base64"),
            (re.compile(r"FromBase64String",            re.I), "base64_decode"),
            (re.compile(r"System\.Reflection\.Assembly", re.I), "reflection_assembly"),
            (re.compile(r"VirtualAlloc|WriteProcessMemory", re.I), "injection_api"),
        ]

        pshome5 = os.environ.get("PSHOME", r"C:\Windows\System32\WindowsPowerShell\v1.0")
        pshome7 = r"C:\Program Files\PowerShell\7"

        profile_candidates = [
            (pshome5 + r"\profile.ps1",                        "AllUsersAllHosts_PS5"),
            (pshome5 + r"\Microsoft.PowerShell_profile.ps1",   "AllUsersCurrentHost_PS5"),
            (pshome7 + r"\profile.ps1",                        "AllUsersAllHosts_PS7"),
            (pshome7 + r"\Microsoft.PowerShell_profile.ps1",   "AllUsersCurrentHost_PS7"),
        ]

        # Per-user profiles
        users_root = r"C:\Users"
        try:
            for user_dir in os.scandir(users_root):
                if not user_dir.is_dir():
                    continue
                uname = user_dir.name
                for rel, label in [
                    (r"Documents\WindowsPowerShell\profile.ps1", "User_%s_AllHosts_PS5" % uname),
                    (r"Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1", "User_%s_CurrentHost_PS5" % uname),
                    (r"Documents\PowerShell\profile.ps1",        "User_%s_AllHosts_PS7" % uname),
                    (r"Documents\PowerShell\Microsoft.PowerShell_profile.ps1", "User_%s_CurrentHost_PS7" % uname),
                ]:
                    profile_candidates.append((os.path.join(user_dir.path, rel), label))
        except Exception:
            pass

        profiles = []
        flagged  = []
        errors   = []

        for path, label in profile_candidates:
            if not os.path.isfile(path):
                continue
            try:
                stat = os.stat(path)
                with open(path, "r", encoding="utf-8", errors="replace") as fh:
                    content = fh.read()
                hits = []
                for pattern, plabel in dangerous_patterns:
                    if pattern.search(content):
                        hits.append(plabel)
                entry = {
                    "label":    label,
                    "path":     path,
                    "size":     stat.st_size,
                    "modified": stat.st_mtime,
                    "content":  content[:5000],
                    "flags":    hits,
                }
                profiles.append(entry)
                if hits:
                    flagged.append(entry)
            except Exception as exc:
                errors.append("%s: %s" % (path, exc))

        result = {
            "profiles_found":  len(profiles),
            "flagged_count":   len(flagged),
            "flagged_profiles": flagged,
            "all_profiles":    profiles,
            "errors":          errors,
        }
        out = json.dumps(result, indent=2, default=str)
        return ({"command": "powershell_profile_audit", "stdout": out, "stderr": "", "exit_code": 0}, out)

    # ------------------------------------------------------------------
    # pipe_and_rpc_server_enum
    # ------------------------------------------------------------------
    def _endpoint_pipe_and_rpc_server_enum(self, params, timeout):
        """
        Enumerate all named pipe servers with owning process information and
        RPC endpoint registrations. Named pipes are a primary C2/lateral-movement
        transport (Cobalt Strike, PsExec, Metasploit). RPC endpoint anomalies
        indicate rootkit or backdoor registration.

        Uses:
          - NtQueryDirectoryFile on \\\\Device\\\\NamedPipe (via \\\\.\\\\pipe) to list pipes
          - CreateToolhelp32Snapshot to correlate pipe names to process handles
          - PowerShell portutil for RPC endpoint registration enumeration
        Flags pipes matching known C2 tool patterns.
        """
        self._block_if_not_windows("pipe_and_rpc_server_enum")
        import ctypes
        import ctypes.wintypes

        # ── 1. Enumerate named pipes via filesystem listing ──
        pipes = []
        errors = []

        # Known C2 / tool pipe patterns
        C2_PIPE_PATTERNS = [
            re.compile(r"^msagent_", re.I),
            re.compile(r"^mojo\.", re.I),
            re.compile(r"postex_", re.I),
            re.compile(r"^\\\\\.\\pipe\\[a-f0-9]{8,}$", re.I),  # random hex
            re.compile(r"MSSE-[0-9]+-server", re.I),
            re.compile(r"^status_[0-9a-f]{6,}$", re.I),
            re.compile(r"^[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}$"),  # GUID pipe
            re.compile(r"interprocess_[0-9]+", re.I),
            re.compile(r"^(tsvcpipe|6e7645c4|ntsvcs|scerpc|atsvc)", re.I),
        ]
        KNOWN_SAFE_PIPES = {
            "lsass", "svcctl", "samr", "netlogon", "protected_storage",
            "winreg", "spoolss", "plugplay", "epmapper", "eventlog",
            "w32time", "trkwks", "tapsrv", "terminalserver",
            "ntsvcs", "scerpc", "atsvc", "winpipe",
        }

        try:
            pipe_dir = r"\\.\pipe"
            kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
            INVALID_HANDLE = ctypes.wintypes.HANDLE(-1).value

            hfind = kernel32.FindFirstFileW(pipe_dir + r"\*", ctypes.byref(ctypes.create_string_buffer(600)))
            if hfind != INVALID_HANDLE:
                kernel32.FindClose(hfind)
        except Exception:
            pass

        # Use PowerShell to enumerate pipes (most reliable cross-version approach)
        ps_pipe_script = (
            r"try{"
            r"$pipes=Get-ChildItem \\.\pipe -ErrorAction SilentlyContinue;"
            r"$results=@();"
            r"foreach($p in $pipes){"
            r"$results += [PSCustomObject]@{name=$p.Name;full_path=$p.FullName};"
            r"};"
            r"$results | ConvertTo-Json -Compress;"
            r"}catch{Write-Output '[]'}"
        )
        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_pipe_script],
                min(timeout, 20),
            )
            raw = (out or "").strip()
            if raw and raw != "[]":
                parsed = json.loads(raw) if raw.startswith(("[", "{")) else []
                if isinstance(parsed, dict):
                    parsed = [parsed]
                for item in parsed:
                    pname = item.get("name", "")
                    pname_lower = pname.lower()
                    flags = []
                    for pattern in C2_PIPE_PATTERNS:
                        if pattern.search(pname):
                            flags.append("c2_pattern_match")
                            break
                    if not any(s in pname_lower for s in KNOWN_SAFE_PIPES):
                        if re.match(r"^[a-f0-9]{8,}$", pname_lower):
                            flags.append("random_hex_name")
                        if re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", pname_lower):
                            flags.append("guid_pipe_name")
                    item["flags"] = flags
                    pipes.append(item)
        except Exception as exc:
            errors.append("pipe_enum: %s" % exc)

        # ── 2. Correlate pipes to processes via PowerShell handle query ──
        ps_handle_script = (
            r"try{"
            r"$handles=Get-Process -ErrorAction SilentlyContinue | "
            r"Select-Object Id,Name,@{N='Pipes';"
            r"E={try{[System.IO.Directory]::GetFiles('\\.\pipe') | "
            r"Where-Object{$_ -ne $null}}catch{@()}}};"
            r"$handles | ConvertTo-Json -Depth 2 -Compress;"
            r"}catch{Write-Output '[]'}"
        )
        # Note: handle-to-pipe correlation requires NtQuerySystemInformation
        # which needs a native approach; use named pipe server query via PowerShell
        pipe_process_map = {}
        try:
            code, out, err = self.command_runner(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command",
                 r"Get-WmiObject Win32_Process | Select-Object ProcessId,Name | ConvertTo-Json -Compress"],
                min(timeout, 15),
            )
            raw = (out or "").strip()
            if raw:
                procs = json.loads(raw) if raw.startswith(("[", "{")) else []
                if isinstance(procs, dict):
                    procs = [procs]
                for p in procs:
                    pid = str(p.get("ProcessId", ""))
                    if pid:
                        pipe_process_map[pid] = p.get("Name", "")
        except Exception:
            pass

        # ── 3. RPC endpoint registration ──
        rpc_endpoints = []
        try:
            code, out, err = self.command_runner(
                ["netsh", "rpc", "filter", "show", "filter"],
                min(timeout, 10),
            )
            rpc_endpoints = [{"source": "netsh_rpc_filter", "output": (out or "").strip()[:2000]}]
        except Exception as exc:
            errors.append("rpc_filter: %s" % exc)

        flagged_pipes = [p for p in pipes if p.get("flags")]

        result = {
            "total_pipes":     len(pipes),
            "flagged_count":   len(flagged_pipes),
            "flagged_pipes":   flagged_pipes,
            "all_pipes":       pipes,
            "rpc_endpoints":   rpc_endpoints,
            "errors":          errors,
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "pipe_and_rpc_server_enum", "stdout": out_str, "stderr": "", "exit_code": 0}, out_str)

    # ------------------------------------------------------------------
    # process_injection_scan
    # ------------------------------------------------------------------
    def _endpoint_process_injection_scan(self, params, timeout):
        """
        Detect process injection across all running processes by looking for:
          1. Memory regions with RWX or RX protection and no backing image file
             (orphaned shellcode / reflectively-loaded PE)
          2. Memory regions with MEM_PRIVATE protection in executable areas
             (hallmark of VirtualAllocEx + WriteProcessMemory injection)
          3. Remote thread start addresses outside any known module range
             (CreateRemoteThread injection — start address points to heap/stack)
          4. PE headers (MZ signature) found in non-image, non-module regions
             (reflective DLL injection, process hollowing)

        Uses VirtualQueryEx + ReadProcessMemory + CreateToolhelp32Snapshot
        exclusively via ctypes — no child processes spawned.
        """
        self._block_if_not_windows("process_injection_scan")
        import ctypes
        import ctypes.wintypes
        import struct

        PROCESS_QUERY_INFORMATION = 0x0400
        PROCESS_VM_READ           = 0x0010
        TH32CS_SNAPPROCESS        = 0x00000002
        TH32CS_SNAPTHREAD         = 0x00000004
        LIST_MODULES_ALL          = 0x03

        MEM_COMMIT   = 0x1000
        MEM_PRIVATE  = 0x20000
        MEM_IMAGE    = 0x1000000

        PAGE_EXECUTE                  = 0x10
        PAGE_EXECUTE_READ             = 0x20
        PAGE_EXECUTE_READWRITE        = 0x40
        PAGE_EXECUTE_WRITECOPY        = 0x80

        EXECUTABLE_PAGES = {
            PAGE_EXECUTE, PAGE_EXECUTE_READ,
            PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY,
        }

        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        psapi    = ctypes.WinDLL("psapi",    use_last_error=True)

        class MEMORY_BASIC_INFORMATION(ctypes.Structure):
            _fields_ = [
                ("BaseAddress",       ctypes.c_size_t),
                ("AllocationBase",    ctypes.c_size_t),
                ("AllocationProtect", ctypes.wintypes.DWORD),
                ("RegionSize",        ctypes.c_size_t),
                ("State",             ctypes.wintypes.DWORD),
                ("Protect",           ctypes.wintypes.DWORD),
                ("Type",              ctypes.wintypes.DWORD),
            ]

        class PROCESSENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize",              ctypes.wintypes.DWORD),
                ("cntUsage",            ctypes.wintypes.DWORD),
                ("th32ProcessID",       ctypes.wintypes.DWORD),
                ("th32DefaultHeapID",   ctypes.POINTER(ctypes.c_ulong)),
                ("th32ModuleID",        ctypes.wintypes.DWORD),
                ("cntThreads",          ctypes.wintypes.DWORD),
                ("th32ParentProcessID", ctypes.wintypes.DWORD),
                ("pcPriClassBase",      ctypes.c_long),
                ("dwFlags",             ctypes.wintypes.DWORD),
                ("szExeFile",           ctypes.c_char * 260),
            ]

        class THREADENTRY32(ctypes.Structure):
            _fields_ = [
                ("dwSize",             ctypes.wintypes.DWORD),
                ("cntUsage",           ctypes.wintypes.DWORD),
                ("th32ThreadID",       ctypes.wintypes.DWORD),
                ("th32OwnerProcessID", ctypes.wintypes.DWORD),
                ("tpBasePri",          ctypes.c_long),
                ("tpDeltaPri",         ctypes.c_long),
                ("dwFlags",            ctypes.wintypes.DWORD),
            ]

        findings = []
        errors   = []
        pids_scanned = 0

        # ── Enumerate processes ──
        snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
        if snap == ctypes.wintypes.HANDLE(-1).value:
            return ({"command": "process_injection_scan", "stdout": "{}", "stderr": "snapshot failed", "exit_code": 1}, "snapshot failed")

        pe = PROCESSENTRY32()
        pe.dwSize = ctypes.sizeof(PROCESSENTRY32)
        pid_names = []
        try:
            if kernel32.Process32First(snap, ctypes.byref(pe)):
                while True:
                    pid_names.append((pe.th32ProcessID, pe.szExeFile.decode("cp1252", errors="replace")))
                    if not kernel32.Process32Next(snap, ctypes.byref(pe)):
                        break
        finally:
            kernel32.CloseHandle(snap)

        mbi  = MEMORY_BASIC_INFORMATION()
        buf4 = ctypes.create_string_buffer(4)

        for pid, proc_name in pid_names:
            if pid <= 4:
                continue  # skip System / Idle
            hproc = kernel32.OpenProcess(
                PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid
            )
            if not hproc:
                continue

            pids_scanned += 1
            proc_findings = []

            try:
                # ── Build module ranges for this process ──
                mods = (ctypes.wintypes.HMODULE * 512)()
                needed = ctypes.wintypes.DWORD(0)
                mod_ranges = []  # list of (base, end) tuples
                buf_name = (ctypes.c_wchar * 1024)()
                if psapi.EnumProcessModulesEx(
                    hproc, mods, ctypes.sizeof(mods),
                    ctypes.byref(needed), LIST_MODULES_ALL,
                ):
                    count = needed.value // ctypes.sizeof(ctypes.wintypes.HMODULE)
                    for i in range(count):
                        mod_base = mods[i]
                        if not psapi.GetModuleFileNameExW(hproc, mods[i], buf_name, 1024):
                            continue
                        # Get module size via VirtualQueryEx on module base
                        q_size = kernel32.VirtualQueryEx(
                            hproc,
                            ctypes.c_size_t(mod_base),
                            ctypes.byref(mbi),
                            ctypes.sizeof(mbi),
                        )
                        if q_size:
                            mod_ranges.append((mbi.AllocationBase, mbi.AllocationBase + mbi.RegionSize * 64))

                # ── Walk virtual address space ──
                addr = 0
                MAX_ADDR = (1 << 47)  # user-mode ceiling on x64
                while addr < MAX_ADDR:
                    q_size = kernel32.VirtualQueryEx(
                        hproc,
                        ctypes.c_size_t(addr),
                        ctypes.byref(mbi),
                        ctypes.sizeof(mbi),
                    )
                    if not q_size:
                        break

                    region_size = mbi.RegionSize
                    if region_size == 0:
                        break

                    if (
                        mbi.State == MEM_COMMIT
                        and mbi.Protect in EXECUTABLE_PAGES
                    ):
                        region_flags = []

                        # Flag 1: executable + private (not backed by image)
                        if mbi.Type == MEM_PRIVATE:
                            region_flags.append("executable_private_memory")

                        # Flag 2: RWX specifically
                        if mbi.Protect == PAGE_EXECUTE_READWRITE:
                            region_flags.append("rwx_region")

                        # Flag 3: Not within any known module range
                        base = mbi.BaseAddress
                        in_module = any(lo <= base < hi for lo, hi in mod_ranges)
                        if not in_module:
                            region_flags.append("executable_outside_module")

                        if region_flags:
                            # Flag 4: Check for MZ header (PE in non-image memory)
                            read_size = ctypes.c_size_t(0)
                            read_ok = kernel32.ReadProcessMemory(
                                hproc,
                                ctypes.c_size_t(base),
                                buf4,
                                4,
                                ctypes.byref(read_size),
                            )
                            has_pe_header = (
                                read_ok
                                and read_size.value >= 2
                                and buf4.raw[:2] == b"MZ"
                            )
                            if has_pe_header:
                                region_flags.append("pe_header_in_non_image_region")

                            proc_findings.append({
                                "base_address":    "0x%x" % base,
                                "region_size":     region_size,
                                "protect":         "0x%x" % mbi.Protect,
                                "type":            "PRIVATE" if mbi.Type == MEM_PRIVATE else "MAPPED" if mbi.Type == 0x40000 else "IMAGE",
                                "flags":           region_flags,
                                "has_pe_header":   has_pe_header,
                            })

                    addr += max(region_size, 1)

            except Exception as exc:
                errors.append("pid %d: %s" % (pid, exc))
            finally:
                kernel32.CloseHandle(hproc)

            if proc_findings:
                severity = "CRITICAL" if any("pe_header" in f for pf in proc_findings for f in pf["flags"]) else "HIGH"
                findings.append({
                    "pid":      pid,
                    "process":  proc_name,
                    "regions":  proc_findings,
                    "count":    len(proc_findings),
                    "severity": severity,
                })

        result = {
            "processes_scanned": pids_scanned,
            "injected_processes": len(findings),
            "critical": [f for f in findings if f.get("severity") == "CRITICAL"],
            "high":     [f for f in findings if f.get("severity") == "HIGH"],
            "findings": findings,
            "errors":   errors[:50],
        }
        out_str = json.dumps(result, indent=2, default=str)
        return ({"command": "process_injection_scan", "stdout": out_str, "stderr": "", "exit_code": 0}, out_str)

    def _endpoint_event_log_extraction(self, params, timeout):
        self._block_if_not_windows("event_log_extraction")
        log_name = self._require_param(params, "log_name")
        count = self._require_int_param(params, "count", minimum=1, maximum=5000)
        cmd = ["wevtutil", "qe", log_name, "/c:%d" % count, "/rd:true", "/f:text"]
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_event_log_enriched(self, params, timeout):
        self._block_if_not_windows("event_log_enriched")
        if "channel" in params:
            log_name = self._require_param(params, "channel")
        else:
            log_name = self._require_param(params, "log_name")
        count = self._require_int_param(params, "count", minimum=1, maximum=5000) if "count" in params else 100
        event_id = params.get("event_id")
        if event_id is not None:
            event_id = self._require_int_param(params, "event_id", minimum=1, maximum=65535)
            filter_fragment = "@{LogName='%s';Id=%d}" % (log_name, event_id)
        else:
            filter_fragment = "@{LogName='%s'}" % log_name
        ps_script = (
            "$events=Get-WinEvent -FilterHashtable %s -MaxEvents %d -ErrorAction Stop | "
            "Select-Object TimeCreated,Id,LevelDisplayName,ProviderName,MachineName,TaskDisplayName,OpcodeDisplayName,KeywordsDisplayNames,Message;"
            "$events | ConvertTo-Json -Depth 6"
        ) % (filter_fragment, count)
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Event log enriched query failed: %s" % (stderr or stdout))
        parsed = self._safe_json_loads(stdout)
        if isinstance(parsed, dict):
            records = [parsed]
        elif isinstance(parsed, list):
            records = parsed
        else:
            records = []
        result = {
            "exit_code": code,
            "log_name": log_name,
            "event_id": event_id,
            "count": len(records),
            "records": records,
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<event-log-enriched-script>"],
        }
        raw = json.dumps(
            {"log_name": log_name, "event_id": event_id, "count": len(records)},
            sort_keys=True,
        )
        return result, raw

    def _endpoint_list_event_log_channels(self, params, timeout):
        del params
        self._block_if_not_windows("list_event_log_channels")
        cmd = [
            "powershell",
            "-NoProfile",
            "-NonInteractive",
            "-Command",
            "Get-WinEvent -ListLog * -ErrorAction SilentlyContinue | Select-Object LogName,RecordCount,IsEnabled,LogType | ConvertTo-Json -Depth 4",
        ]
        code, out, err = self.command_runner(cmd, timeout)
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Event log channel listing failed: %s" % (stderr or stdout))
        parsed = self._safe_json_loads(stdout)
        if isinstance(parsed, dict):
            channels = [parsed]
        elif isinstance(parsed, list):
            channels = parsed
        else:
            channels = []
        result = {
            "exit_code": code,
            "channels": channels,
            "count": len(channels),
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<event-log-channel-list-script>"],
        }
        raw = json.dumps({"count": len(channels)}, sort_keys=True)
        return result, raw

    def _endpoint_network_connections(self, params, timeout):
        self._block_if_not_windows("network_connections")
        return self._exec_command_structured(["netstat", "-ano"], timeout)

    def _endpoint_file_exists_hash(self, params, timeout):
        del timeout
        path = params.get("path")
        if not isinstance(path, str) or len(path.strip()) == 0:
            raise PermissionError("Missing or invalid parameter: path")
        exists = os.path.exists(path)
        result = {"path": path, "exists": exists}
        raw = json.dumps(result, sort_keys=True)
        if exists and os.path.isfile(path):
            with open(path, "rb") as handle:
                digest = hashlib.sha256(handle.read()).hexdigest()
            result["sha256"] = digest
            raw = json.dumps(result, sort_keys=True)
        return result, raw

    def _endpoint_registry_query(self, params, timeout):
        self._block_if_not_windows("registry_query")
        key_path = self._require_param(params, "path")
        value_name = params.get("value_name")
        cmd = ["reg", "query", key_path]
        if value_name:
            value_name = self._require_param(params, "value_name")
            cmd.extend(["/v", value_name])
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_registry_read(self, params, timeout):
        self._block_if_not_windows("registry_read")
        key_path = self._require_param(params, "path")
        recursive = bool(params.get("recursive", True))
        value_name = params.get("value_name")
        cmd = ["reg", "query", key_path]
        if value_name is not None:
            value_name = self._require_param(params, "value_name")
            cmd.extend(["/v", value_name])
        if recursive:
            cmd.append("/s")
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_disk_read(self, params, timeout):
        del timeout
        path = params.get("path")
        if not isinstance(path, str) or not path.strip():
            raise PermissionError("Missing or invalid parameter: path")
        path = path.strip()
        if not os.path.exists(path):
            raise PermissionError("Path does not exist.")

        if os.path.isdir(path):
            recursive = bool(params.get("recursive", False))
            max_items = self._require_int_param(params, "max_items", minimum=1, maximum=100000) if "max_items" in params else 1000
            entries = []
            if recursive:
                for root, dirs, files in os.walk(path):
                    for name in dirs:
                        full = os.path.join(root, name)
                        entries.append({"path": full, "type": "dir"})
                        if len(entries) >= max_items:
                            break
                    if len(entries) >= max_items:
                        break
                    for name in files:
                        full = os.path.join(root, name)
                        size_bytes = 0
                        try:
                            size_bytes = os.path.getsize(full)
                        except Exception:
                            pass
                        entries.append({"path": full, "type": "file", "size_bytes": size_bytes})
                        if len(entries) >= max_items:
                            break
                    if len(entries) >= max_items:
                        break
            else:
                for name in os.listdir(path):
                    full = os.path.join(path, name)
                    if os.path.isdir(full):
                        entries.append({"path": full, "type": "dir"})
                    else:
                        size_bytes = 0
                        try:
                            size_bytes = os.path.getsize(full)
                        except Exception:
                            pass
                        entries.append({"path": full, "type": "file", "size_bytes": size_bytes})
                    if len(entries) >= max_items:
                        break
            result = {
                "path": path,
                "is_directory": True,
                "recursive": recursive,
                "max_items": max_items,
                "items": entries,
            }
            return result, json.dumps(result, sort_keys=True)

        max_bytes = self._require_int_param(params, "max_bytes", minimum=1, maximum=self.max_disk_read_bytes) if "max_bytes" in params else self.max_disk_read_bytes
        with open(path, "rb") as handle:
            data = handle.read(max_bytes + 1)
        truncated = len(data) > max_bytes
        if truncated:
            data = data[:max_bytes]
        sha256 = hashlib.sha256(data).hexdigest()
        result = {
            "path": path,
            "is_directory": False,
            "bytes_read": len(data),
            "truncated": truncated,
            "sha256": sha256,
            "base64": base64.b64encode(data).decode("ascii"),
        }
        try:
            result["utf8_preview"] = data.decode("utf-8")
        except Exception:
            result["utf8_preview"] = None
        return result, json.dumps(
            {
                "path": path,
                "is_directory": False,
                "bytes_read": len(data),
                "truncated": truncated,
                "sha256": sha256,
            },
            sort_keys=True,
        )

    def _endpoint_run_command(self, params, timeout):
        if not self.enable_command_execution:
            raise PermissionError("Command execution is disabled by policy.")
        argv = params.get("argv")
        if not isinstance(argv, list) or len(argv) == 0:
            raise PermissionError("argv must be a non-empty list.")
        normalized = []
        for part in argv:
            if not isinstance(part, str):
                part = str(part)
            if ("\x00" in part) or ("\n" in part) or ("\r" in part):
                raise PermissionError("Invalid control characters in argv.")
            normalized.append(part)
        cwd = params.get("cwd")
        if cwd is not None:
            if not isinstance(cwd, str) or not cwd.strip():
                raise PermissionError("cwd must be a non-empty string.")
            cwd = cwd.strip()
            if not os.path.isdir(cwd):
                raise PermissionError("cwd does not exist.")
            proc = subprocess.run(
                normalized,
                capture_output=True,
                text=True,
                shell=False,
                timeout=timeout,
                check=False,
                cwd=cwd,
            )
            stdout = proc.stdout or ""
            stderr = proc.stderr or ""
            return {
                "command": normalized,
                "stdout": stdout,
                "stderr": stderr,
                "exit_code": proc.returncode,
                "cwd": cwd,
            }, stdout + ("\n" + stderr if stderr else "")
        return self._exec_command_structured(normalized, timeout)

    def _endpoint_list_drives(self, params, timeout):
        del params
        if self._is_windows():
            return self._exec_command_structured(
                [
                    "powershell",
                    "-NoProfile",
                    "-NonInteractive",
                    "-Command",
                    "Get-PSDrive -PSProvider FileSystem | Select-Object Name,Root,Used,Free | ConvertTo-Json -Depth 3",
                ],
                timeout,
            )
        return self._exec_command_structured(["df", "-h"], timeout)

    def _endpoint_terminate_task(self, params, timeout):
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)
        if self._is_windows():
            return self._exec_command_structured(["taskkill", "/PID", str(pid), "/F"], timeout)
        return self._exec_command_structured(["kill", "-9", str(pid)], timeout)

    def _endpoint_process_snapshot_to_disk(self, params, timeout):
        self._block_if_not_windows("process_snapshot_to_disk")
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)
        artifact_dir = self._get_artifact_dir()
        output_dir = params.get("output_dir")
        if isinstance(output_dir, str) and output_dir.strip():
            output_dir = output_dir.strip()
            if not os.path.isdir(output_dir):
                os.makedirs(output_dir)
            artifact_dir = output_dir
        artifact_name = "local_proc_snapshot_%d_%s.dmp" % (pid, str(uuid.uuid4())[:8])
        dump_path = os.path.join(artifact_dir, artifact_name)
        ps_script = (
            "rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump %d '%s' full;"
            "if(!(Test-Path '%s')){throw 'Snapshot failed'};"
            "Write-Output 'SNAPSHOT_OK';"
        ) % (pid, self._ps_quote(dump_path), self._ps_quote(dump_path))
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
            timeout,
        )
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Process snapshot failed: %s" % (stderr or stdout))
        if not os.path.isfile(dump_path):
            raise PermissionError("Snapshot completed but dump file not found.")
        file_size = os.path.getsize(dump_path)
        with open(dump_path, "rb") as handle:
            file_data = handle.read()
        sha256 = hashlib.sha256(file_data).hexdigest()
        artifact = {
            "artifact_type": "process_dump",
            "path": dump_path,
            "sha256": sha256,
            "size_bytes": file_size,
            "pid": pid,
        }
        if file_size <= self.max_inline_artifact_bytes:
            artifact["base64"] = base64.b64encode(file_data).decode("ascii")
        result = {
            "exit_code": code,
            "stdout": stdout,
            "stderr": stderr,
            "artifact": artifact,
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<redacted-sensitive-script>"],
        }
        raw = json.dumps(
            {"artifact_sha256": sha256, "size_bytes": file_size, "pid": pid},
            sort_keys=True,
        )
        return result, raw

    def _endpoint_register_windows_service(self, params, timeout):
        self._block_if_not_windows("register_windows_service")
        service_name = self._require_param(params, "service_name")
        executable_path = params.get("executable_path")
        if not isinstance(executable_path, str) or not executable_path.strip():
            raise PermissionError("Missing parameter: executable_path")
        executable_path = executable_path.strip()
        if not os.path.isfile(executable_path):
            raise PermissionError("executable_path was not found.")
        config_path = params.get("config_path")
        display_name = params.get("display_name") or service_name
        description = params.get("description", "Hawk SOARD Windows service")
        if not isinstance(display_name, str):
            display_name = str(display_name)
        if not isinstance(description, str):
            description = str(description)
        bin_path = '"%s"' % executable_path
        if isinstance(config_path, str) and config_path.strip():
            config_path = config_path.strip()
            bin_path = '%s -c "%s"' % (bin_path, config_path)
        start_now = bool(params.get("start_now", True))

        create_script = (
            "$name='%s';"
            "$display='%s';"
            "$bin='%s';"
            "$desc='%s';"
            "$existing=Get-Service -Name $name -ErrorAction SilentlyContinue;"
            "if($existing){Set-Service -Name $name -StartupType Automatic;"
            "sc.exe config $name binPath= $bin start= auto | Out-Null;"
            "sc.exe description $name $desc | Out-Null;"
            "}else{"
            "New-Service -Name $name -BinaryPathName $bin -DisplayName $display -Description $desc -StartupType Automatic | Out-Null;"
            "};"
            "Write-Output 'SERVICE_REGISTERED';"
        ) % (
            self._ps_quote(service_name),
            self._ps_quote(display_name),
            self._ps_quote(bin_path),
            self._ps_quote(description),
        )
        reg_result, reg_raw = self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", create_script],
            timeout,
        )
        if reg_result["exit_code"] != 0:
            raise PermissionError("register_windows_service failed: %s" % (reg_result.get("stderr") or reg_result.get("stdout")))

        start_result = None
        if start_now:
            start_result, _ = self._exec_command_structured(["sc", "start", service_name], timeout)
        status_result, _ = self._endpoint_windows_service_status({"service_name": service_name}, timeout)
        response = {
            "registration": reg_result,
            "started": start_result,
            "service": status_result,
            "service_name": service_name,
            "display_name": display_name,
            "startup_type": "Automatic",
        }
        return response, reg_raw

    def _endpoint_unregister_windows_service(self, params, timeout):
        self._block_if_not_windows("unregister_windows_service")
        service_name = self._require_param(params, "service_name")
        stop_result, _ = self._exec_command_structured(["sc", "stop", service_name], timeout)
        delete_result, delete_raw = self._exec_command_structured(["sc", "delete", service_name], timeout)
        if delete_result["exit_code"] != 0:
            raise PermissionError("unregister_windows_service failed: %s" % (delete_result.get("stderr") or delete_result.get("stdout")))
        return {"stopped": stop_result, "deleted": delete_result, "service_name": service_name}, delete_raw

    def _endpoint_windows_service_status(self, params, timeout):
        self._block_if_not_windows("windows_service_status")
        service_name = self._require_param(params, "service_name")
        script = (
            "$svc=Get-CimInstance Win32_Service -Filter \"Name='%s'\" -ErrorAction SilentlyContinue;"
            "if(-not $svc){Write-Output '{}';exit 0};"
            "[PSCustomObject]@{"
            "Name=$svc.Name;"
            "DisplayName=$svc.DisplayName;"
            "State=$svc.State;"
            "StartMode=$svc.StartMode;"
            "PathName=$svc.PathName;"
            "} | ConvertTo-Json -Depth 3"
        ) % self._ps_quote(service_name)
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        if code != 0:
            raise PermissionError("windows_service_status failed: %s" % ((err or "") + (out or "")))
        parsed = self._safe_json_loads(out or "")
        if not isinstance(parsed, dict):
            parsed = {}
        result = {
            "service_name": service_name,
            "exists": bool(parsed),
            "details": parsed,
            "exit_code": code,
            "stderr": err or "",
            "stdout": out or "",
        }
        raw = json.dumps({"service_name": service_name, "exists": bool(parsed)}, sort_keys=True)
        return result, raw

    def _endpoint_restart_windows_service(self, params, timeout):
        self._block_if_not_windows("restart_windows_service")
        service_name = self._require_param(params, "service_name")
        restart_result, restart_raw = self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Restart-Service -Name '%s' -Force -ErrorAction Stop; Write-Output 'SERVICE_RESTARTED'" % self._ps_quote(service_name)],
            timeout,
        )
        if restart_result["exit_code"] != 0:
            raise PermissionError("restart_windows_service failed: %s" % (restart_result.get("stderr") or restart_result.get("stdout")))
        status, _ = self._endpoint_windows_service_status({"service_name": service_name}, timeout)
        return {"restart": restart_result, "service": status}, restart_raw

    def _endpoint_register_dynamic_tool(self, params, timeout):
        del timeout
        if not self.enable_command_execution:
            raise PermissionError("Dynamic tool registration is disabled when command execution is disabled.")
        spec = self._build_dynamic_tool_spec(params)
        tools = self._load_dynamic_tools()
        updated = False
        for idx, existing in enumerate(tools):
            if existing.get("tool_id") == spec["tool_id"] or existing.get("action") == spec["action"]:
                tools[idx] = spec
                updated = True
                break
        if not updated:
            tools.append(spec)
        self._save_dynamic_tools(tools)
        self._refresh_dynamic_tools()
        result = {
            "registered": True,
            "updated": updated,
            "tool": copy.deepcopy(spec),
            "dynamic_tools_file": self.dynamic_tools_file,
        }
        return result, json.dumps(result, sort_keys=True)

    def _endpoint_remote_wmic_query(self, params, timeout):
        self._block_if_not_windows("remote_wmic_query")
        target_host = self._require_param(params, "target_host")
        self._validate_remote_target(target_host)
        query = self._require_param(params, "query")
        if query not in self.ALLOWED_WMIC_QUERIES:
            raise PermissionError("WMIC query is not allowlisted.")
        cmd = ["wmic", "/node:%s" % target_host]
        username = params.get("username")
        password = params.get("password")
        if username is not None and password is not None:
            username = self._require_param(params, "username")
            if not isinstance(password, str) or len(password) == 0:
                raise PermissionError("Missing parameter: password")
            cmd.extend(["/user:%s" % username, "/password:%s" % password])
        cmd.extend(self.ALLOWED_WMIC_QUERIES[query][1:])
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_remote_command_execute(self, params, timeout):
        self._block_if_not_windows("remote_command_execute")
        if not self.enable_remote_command_execution:
            raise PermissionError("Remote command execution is disabled by policy.")
        target_host = self._require_param(params, "target_host")
        if len(self.remote_command_allowed_targets) > 0 and target_host not in self.remote_command_allowed_targets:
            raise PermissionError("Remote command target is not allowlisted.")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        argv = params.get("argv")
        if not isinstance(argv, list) or len(argv) == 0:
            raise PermissionError("argv must be a non-empty list.")
        normalized = []
        for part in argv:
            if not isinstance(part, str):
                part = str(part)
            if ("\x00" in part) or ("\n" in part) or ("\r" in part):
                raise PermissionError("Invalid control characters in argv.")
            normalized.append(part)
        exe = normalized[0]
        args = normalized[1:]
        script = self._build_remote_command_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            exe=exe,
            args=args,
        )
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        return {
            "command": normalized,
            "target_host": target_host,
            "stdout": out or "",
            "stderr": err or "",
            "exit_code": code,
        }, (out or "") + ("\n" + err if err else "")

    def _endpoint_remote_registry_query(self, params, timeout):
        self._block_if_not_windows("remote_registry_query")
        if not self.enable_remote_registry_read:
            raise PermissionError("Remote registry read is disabled by policy.")
        target_host = self._require_param(params, "target_host")
        if len(self.remote_registry_allowed_targets) > 0 and target_host not in self.remote_registry_allowed_targets:
            raise PermissionError("Remote registry target is not allowlisted.")
        path = self._require_param(params, "path")
        cmd = ["reg", "query", "\\\\%s\\%s" % (target_host, path)]
        value_name = params.get("value_name")
        if value_name is not None:
            value_name = self._require_param(params, "value_name")
            cmd.extend(["/v", value_name])
        recursive = bool(params.get("recursive", False))
        if recursive:
            cmd.append("/s")
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_remote_config_update(self, params, timeout):
        self._block_if_not_windows("remote_config_update")
        if not self.enable_remote_config_management:
            raise PermissionError("Remote config management is disabled by policy.")
        target_host = self._require_param(params, "target_host")
        self._validate_remote_config_target(target_host)
        remote_path = self._require_remote_path(params, "remote_path")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        content = params.get("content")
        content_base64 = params.get("content_base64")
        if isinstance(content_base64, str) and content_base64:
            try:
                content = base64.b64decode(content_base64.encode("ascii")).decode("utf-8")
            except Exception:
                raise PermissionError("content_base64 is invalid.")
        if not isinstance(content, str):
            raise PermissionError("Missing parameter: content or content_base64")
        backup = bool(params.get("backup", True))
        script = self._build_remote_config_update_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            remote_path=remote_path,
            content=content,
            backup=backup,
        )
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )

    def _endpoint_remote_service_restart(self, params, timeout):
        self._block_if_not_windows("remote_service_restart")
        if not self.enable_remote_config_management:
            raise PermissionError("Remote config management is disabled by policy.")
        target_host = self._require_param(params, "target_host")
        self._validate_remote_config_target(target_host)
        service_name = self._require_param(params, "service_name")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        script = self._build_remote_service_restart_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            service_name=service_name,
        )
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )

    def _endpoint_remote_process_restart(self, params, timeout):
        self._block_if_not_windows("remote_process_restart")
        if not self.enable_remote_config_management:
            raise PermissionError("Remote config management is disabled by policy.")
        target_host = self._require_param(params, "target_host")
        self._validate_remote_config_target(target_host)
        process_name = self._require_param(params, "process_name")
        executable_path = self._require_param(params, "executable_path")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        args = params.get("arguments", [])
        if args is None:
            args = []
        if not isinstance(args, list):
            raise PermissionError("arguments must be a list.")
        normalized_args = []
        for arg in args:
            if not isinstance(arg, str):
                arg = str(arg)
            if ("\x00" in arg) or ("\n" in arg) or ("\r" in arg):
                raise PermissionError("Invalid control characters in arguments.")
            normalized_args.append(arg)
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        script = self._build_remote_process_restart_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            process_name=process_name,
            executable_path=executable_path,
            arguments=normalized_args,
        )
        return self._exec_command_structured(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )


    def _build_dynamic_tool_spec(self, params):
        tool_id = self._require_param(params, "tool_id")
        name = params.get("name")
        if not isinstance(name, str) or not name.strip():
            raise PermissionError("Missing parameter: name")
        name = name.strip()
        if ("\x00" in name) or ("\n" in name) or ("\r" in name):
            raise PermissionError("Invalid characters in parameter: name")
        category = str(params.get("category", "ENDPOINT")).upper()
        if category not in self.ALLOWED_CATEGORIES:
            raise PermissionError("Invalid category for dynamic tool.")
        action = self._require_param(params, "action").lower()
        if not re.match(r"^[a-z0-9_]{2,128}$", action):
            raise PermissionError("Invalid action format for dynamic tool.")
        command = params.get("command")
        if not isinstance(command, list) or len(command) == 0:
            raise PermissionError("command must be a non-empty list.")
        normalized_cmd = []
        for part in command:
            if not isinstance(part, str):
                part = str(part)
            if ("\x00" in part) or ("\n" in part) or ("\r" in part):
                raise PermissionError("Invalid control characters in command.")
            normalized_cmd.append(part)
        description = str(params.get("description", "Dynamic executable tool."))
        if ("\x00" in description) or ("\n" in description) or ("\r" in description):
            raise PermissionError("Invalid characters in parameter: description")
        requires_approval = bool(params.get("requires_approval", True))
        risk = str(params.get("risk", "HIGH")).upper()
        parameters_schema = params.get("parameters_schema", {})
        if not isinstance(parameters_schema, dict):
            raise PermissionError("parameters_schema must be an object.")
        triggers = params.get("triggers", [])
        if not isinstance(triggers, list):
            raise PermissionError("triggers must be a list.")
        return {
            "tool_id": tool_id,
            "name": name,
            "category": category,
            "action": action,
            "execution_mode": "executable",
            "requires_approval": requires_approval,
            "risk": risk,
            "description": description,
            "triggers": [str(x) for x in triggers if isinstance(x, str) and x],
            "parameters_schema": parameters_schema,
            "command": normalized_cmd,
        }

    def _execute_dynamic_tool(self, action, params, timeout):
        spec = self.dynamic_tool_actions.get(action)
        if not spec:
            raise ValueError("Action has no executor handler: %s" % action)
        command = self._render_dynamic_command(spec["command"], params)
        return self._exec_command_structured(command, timeout)

    def _render_dynamic_command(self, command_template, params):
        if not isinstance(params, dict):
            raise PermissionError("Task parameters must be a structured object.")
        rendered = []
        token_re = re.compile(r"\{([A-Za-z0-9_]+)\}")
        for part in command_template:
            if not isinstance(part, str):
                part = str(part)

            def _replace(match):
                key = match.group(1)
                if key not in params:
                    raise PermissionError("Missing parameter for command template: %s" % key)
                value = params[key]
                if not isinstance(value, str):
                    value = str(value)
                if ("\x00" in value) or ("\n" in value) or ("\r" in value):
                    raise PermissionError("Invalid control characters in parameter: %s" % key)
                return value

            rendered.append(token_re.sub(_replace, part))
        return rendered

    def _endpoint_memory_suspect_modules(self, params, timeout):
        self._block_if_not_windows("memory_suspect_modules")
        pid = self._require_int_param(params, "pid", minimum=0, maximum=2147483647) if "pid" in params else 0
        target_host = params.get("target_host")
        if target_host:
            target_host = self._require_param(params, "target_host")
            self._validate_remote_target(target_host)
        if pid > 0:
            cmd = ["tasklist", "/m", "/fi", "PID eq %d" % pid]
        else:
            cmd = ["tasklist", "/m"]
        if target_host:
            cmd.extend(["/s", target_host])
        return self._exec_command_structured(cmd, timeout)

    def _endpoint_living_off_land_detector(self, params, timeout):
        self._block_if_not_windows("living_off_land_detector")
        target_host = params.get("target_host")
        if target_host:
            target_host = self._require_param(params, "target_host")
            self._validate_remote_target(target_host)
        cmd = ["wmic"]
        if target_host:
            cmd.append("/node:%s" % target_host)
        cmd.extend(["process", "get", "ProcessId,Name,CommandLine", "/format:csv"])
        result, raw = self._exec_command_structured(cmd, timeout)
        suspicious_bins = [
            "rundll32.exe",
            "regsvr32.exe",
            "mshta.exe",
            "wmic.exe",
            "powershell.exe",
            "cscript.exe",
            "wscript.exe",
            "certutil.exe",
            "bitsadmin.exe",
        ]
        findings = []
        lowered_raw = raw.lower()
        for binary in suspicious_bins:
            if binary in lowered_raw:
                findings.append(binary)
        enriched = {
            "matched_lolbins": sorted(set(findings)),
            "raw_scan": result,
        }
        return enriched, raw

    def _endpoint_remote_process_snapshot(self, params, timeout):
        self._block_if_not_windows("remote_process_snapshot")
        if not self.enable_remote_process_snapshot:
            raise PermissionError("Remote process snapshot is disabled by policy.")

        target_host = self._require_param(params, "target_host")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)

        if len(self.remote_snapshot_allowed_targets) > 0 and target_host not in self.remote_snapshot_allowed_targets:
            raise PermissionError("Remote snapshot target is not allowlisted.")

        artifact_dir = self._get_artifact_dir()
        artifact_id = str(uuid.uuid4())
        artifact_name = "proc_snapshot_%s_%s_%d.dmp" % (
            target_host.replace(".", "_"),
            artifact_id[:8],
            pid,
        )
        local_path = os.path.join(artifact_dir, artifact_name)
        remote_path = r"C:\Windows\Temp\%s" % artifact_name
        script = self._build_remote_snapshot_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            pid=pid,
            remote_path=remote_path,
            local_path=local_path,
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Remote process snapshot failed: %s" % (stderr or stdout))
        if not os.path.isfile(local_path):
            raise PermissionError("Snapshot completed but no artifact was retrieved.")

        file_size = os.path.getsize(local_path)
        with open(local_path, "rb") as handle:
            file_data = handle.read()
        sha256 = hashlib.sha256(file_data).hexdigest()
        artifact = {
            "artifact_type": "process_dump",
            "path": local_path,
            "sha256": sha256,
            "size_bytes": file_size,
            "target_host": target_host,
            "pid": pid,
        }
        if file_size <= self.max_inline_artifact_bytes:
            artifact["base64"] = base64.b64encode(file_data).decode("ascii")

        result = {
            "exit_code": code,
            "stdout": stdout,
            "stderr": stderr,
            "artifact": artifact,
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<redacted-sensitive-script>"],
        }
        raw = json.dumps(
            {
                "artifact_sha256": sha256,
                "size_bytes": file_size,
                "target_host": target_host,
                "pid": pid,
            },
            sort_keys=True,
        )
        return result, raw

    def _endpoint_remote_process_snapshot_by_name(self, params, timeout):
        self._block_if_not_windows("remote_process_snapshot_by_name")
        if not self.enable_remote_process_snapshot:
            raise PermissionError("Remote process snapshot is disabled by policy.")

        target_host = self._require_param(params, "target_host")
        process_name = self._require_param(params, "process_name")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username

        if len(self.remote_snapshot_allowed_targets) > 0 and target_host not in self.remote_snapshot_allowed_targets:
            raise PermissionError("Remote snapshot target is not allowlisted.")

        artifact_dir = self._get_artifact_dir()
        artifact_id = str(uuid.uuid4())
        artifact_name = "proc_snapshot_%s_%s_%s.dmp" % (
            target_host.replace(".", "_"),
            artifact_id[:8],
            process_name.replace(".", "_"),
        )
        local_path = os.path.join(artifact_dir, artifact_name)
        remote_path = r"C:\Windows\Temp\%s" % artifact_name
        script = self._build_remote_snapshot_by_name_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            process_name=process_name,
            remote_path=remote_path,
            local_path=local_path,
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Remote process snapshot by name failed: %s" % (stderr or stdout))
        if not os.path.isfile(local_path):
            raise PermissionError("Snapshot completed but no artifact was retrieved.")

        file_size = os.path.getsize(local_path)
        with open(local_path, "rb") as handle:
            file_data = handle.read()
        sha256 = hashlib.sha256(file_data).hexdigest()
        artifact = {
            "artifact_type": "process_dump",
            "path": local_path,
            "sha256": sha256,
            "size_bytes": file_size,
            "target_host": target_host,
            "process_name": process_name,
        }
        if file_size <= self.max_inline_artifact_bytes:
            artifact["base64"] = base64.b64encode(file_data).decode("ascii")

        resolved_pid = self._extract_first_int(stdout)
        result = {
            "exit_code": code,
            "stdout": stdout,
            "stderr": stderr,
            "artifact": artifact,
            "resolved_pid": resolved_pid,
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<redacted-sensitive-script>"],
        }
        raw = json.dumps(
            {
                "artifact_sha256": sha256,
                "size_bytes": file_size,
                "target_host": target_host,
                "process_name": process_name,
                "resolved_pid": resolved_pid,
            },
            sort_keys=True,
        )
        return result, raw

    def _endpoint_remote_file_system_list(self, params, timeout):
        self._block_if_not_windows("remote_file_system_list")
        if not self.enable_remote_file_collection:
            raise PermissionError("Remote file listing is disabled by policy.")

        target_host = self._require_param(params, "target_host")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        remote_path = self._require_remote_path(params, "remote_path")
        max_items = self._require_int_param(params, "max_items", minimum=1, maximum=2000) if "max_items" in params else 200
        recursive = bool(params.get("recursive", False))

        self._validate_remote_file_target(target_host)
        script = self._build_remote_list_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            remote_path=remote_path,
            max_items=max_items,
            recursive=recursive,
        )
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        if code != 0:
            raise PermissionError("Remote file listing failed: %s" % ((err or "") + (out or "")))
        stdout = out or ""
        entries = self._safe_json_loads(stdout)
        result = {
            "exit_code": code,
            "entries": entries if isinstance(entries, list) else [],
            "entry_count": len(entries) if isinstance(entries, list) else 0,
            "target_host": target_host,
            "remote_path": remote_path,
            "recursive": recursive,
            "max_items": max_items,
        }
        return result, json.dumps(result, sort_keys=True)

    def _endpoint_remote_file_collect(self, params, timeout):
        self._block_if_not_windows("remote_file_collect")
        if not self.enable_remote_file_collection:
            raise PermissionError("Remote file collection is disabled by policy.")

        target_host = self._require_param(params, "target_host")
        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or len(password) == 0:
            raise PermissionError("Missing parameter: password")
        domain = params.get("domain", "")
        if domain:
            domain = self._require_param(params, "domain")
            user_ref = "%s\\%s" % (domain, username)
        else:
            user_ref = username
        remote_path = self._require_remote_path(params, "remote_path")

        self._validate_remote_file_target(target_host)
        artifact_dir = self._get_artifact_dir()
        artifact_id = str(uuid.uuid4())
        local_name = "remote_file_%s_%s_%s" % (
            target_host.replace(".", "_"),
            artifact_id[:8],
            os.path.basename(remote_path).replace(" ", "_"),
        )
        local_path = os.path.join(artifact_dir, local_name)
        script = self._build_remote_collect_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            remote_path=remote_path,
            local_path=local_path,
        )
        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )
        stdout = out or ""
        stderr = err or ""
        if code != 0:
            raise PermissionError("Remote file collection failed: %s" % (stderr or stdout))
        if not os.path.isfile(local_path):
            raise PermissionError("Remote file collection completed but no artifact was retrieved.")

        file_size = os.path.getsize(local_path)
        with open(local_path, "rb") as handle:
            file_data = handle.read()
        sha256 = hashlib.sha256(file_data).hexdigest()
        artifact = {
            "artifact_type": "remote_file",
            "path": local_path,
            "sha256": sha256,
            "size_bytes": file_size,
            "target_host": target_host,
            "source_path": remote_path,
        }
        if file_size <= self.max_inline_artifact_bytes:
            artifact["base64"] = base64.b64encode(file_data).decode("ascii")

        result = {
            "exit_code": code,
            "stdout": stdout,
            "stderr": stderr,
            "artifact": artifact,
            "command": ["powershell", "-NoProfile", "-NonInteractive", "-Command", "<redacted-sensitive-script>"],
        }
        raw = json.dumps(
            {
                "artifact_sha256": sha256,
                "size_bytes": file_size,
                "target_host": target_host,
                "source_path": remote_path,
            },
            sort_keys=True,
        )
        return result, raw

    def _network_ping(self, params, timeout):
        target = self._require_param(params, "target")
        count = self._require_int_param(params, "count", minimum=1, maximum=10) if "count" in params else 4
        if self._is_windows():
            cmd = ["ping", "-n", str(count), target]
        else:
            cmd = ["ping", "-c", str(count), target]
        return self._exec_command_structured(cmd, timeout)

    def _network_traceroute(self, params, timeout):
        target = self._require_param(params, "target")
        if self._is_windows():
            cmd = ["tracert", target]
        else:
            cmd = ["traceroute", target]
        return self._exec_command_structured(cmd, timeout)

    def _network_dns_lookup(self, params, timeout):
        target = self._require_param(params, "target")
        return self._exec_command_structured(["nslookup", target], timeout)

    def _network_arp_table(self, params, timeout):
        if self._is_windows():
            cmd = ["arp", "-a"]
        else:
            cmd = ["arp", "-an"]
        return self._exec_command_structured(cmd, timeout)

    def _network_route_table(self, params, timeout):
        if self._is_windows():
            cmd = ["route", "print"]
        else:
            cmd = ["route", "-n"]
        return self._exec_command_structured(cmd, timeout)

    def _network_smb_sessions(self, params, timeout):
        self._block_if_not_windows("smb_sessions")
        return self._exec_command_structured(["net", "session"], timeout)

    def _network_smb_share_query(self, params, timeout):
        target = self._require_param(params, "target")
        if self._is_windows():
            return self._exec_command_structured(["net", "view", "\\\\%s" % target], timeout)

        smbclient_bin = shutil.which("smbclient")
        if smbclient_bin:
            return self._exec_command_structured([smbclient_bin, "-L", "//%s" % target, "-N"], timeout)
        raise PermissionError("smb_share_query requires smbclient on non-Windows nodes.")

    def _network_rpc_query(self, params, timeout):
        target = self._require_param(params, "target")
        endpoint = params.get("endpoint")
        if endpoint is not None:
            endpoint = self._require_param(params, "endpoint")

        if self._is_windows():
            rpcping_bin = shutil.which("rpcping") or "rpcping"
            cmd = [rpcping_bin, "-s", target]
            if endpoint:
                cmd.extend(["-e", endpoint])
            return self._exec_command_structured(cmd, timeout)

        rpcinfo_bin = shutil.which("rpcinfo")
        if rpcinfo_bin:
            return self._exec_command_structured([rpcinfo_bin, "-p", target], timeout)
        raise PermissionError("rpc_query requires rpcping (Windows) or rpcinfo (Linux).")

    def _network_http_client(self, params, timeout):
        del timeout
        url = params.get("url")
        if not isinstance(url, str) or not url.strip():
            raise PermissionError("Missing or invalid parameter: url")
        url = url.strip()
        if not (url.startswith("http://") or url.startswith("https://")):
            raise PermissionError("url must start with http:// or https://")
        method = str(params.get("method", "GET")).upper()
        if method not in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"):
            raise PermissionError("Unsupported HTTP method.")
        headers = params.get("headers", {})
        if not isinstance(headers, dict):
            raise PermissionError("headers must be an object.")
        request_timeout = self._require_int_param(params, "timeout", minimum=1, maximum=300) if "timeout" in params else 15
        body = params.get("body", "")
        if body is None:
            body = ""
        if not isinstance(body, str):
            body = str(body)
        data = body.encode("utf-8") if method in ("POST", "PUT", "PATCH", "DELETE") else None
        req = urllib.request.Request(url=url, method=method, data=data)
        for key, value in headers.items():
            if not isinstance(key, str):
                continue
            if value is None:
                continue
            req.add_header(key, str(value))
        verify_tls = bool(params.get("verify_tls", not self._to_bool(self.config.get("insecure", False))))
        context = None
        if url.startswith("https://") and not verify_tls:
            context = ssl._create_unverified_context()
        try:
            with urllib.request.urlopen(req, timeout=request_timeout, context=context) as resp:
                payload = resp.read()
                decoded = payload.decode("utf-8", errors="replace")
                response = {
                    "url": url,
                    "method": method,
                    "status_code": int(resp.getcode()),
                    "headers": dict(resp.headers.items()),
                    "body": decoded,
                    "bytes": len(payload),
                }
                return response, json.dumps(
                    {"url": url, "method": method, "status_code": int(resp.getcode()), "bytes": len(payload)},
                    sort_keys=True,
                )
        except urllib.error.HTTPError as exc:
            payload = exc.read() if hasattr(exc, "read") else b""
            decoded = payload.decode("utf-8", errors="replace")
            response = {
                "url": url,
                "method": method,
                "status_code": int(exc.code),
                "headers": dict(exc.headers.items()) if exc.headers else {},
                "body": decoded,
                "bytes": len(payload),
            }
            return response, json.dumps(
                {"url": url, "method": method, "status_code": int(exc.code), "bytes": len(payload)},
                sort_keys=True,
            )
        except Exception as exc:
            raise PermissionError("http_client failed: %s" % str(exc))

    def _network_smb_client(self, params, timeout):
        target = self._require_param(params, "target")
        operation = str(params.get("operation", "list_shares")).strip().lower()
        if operation not in ("list_shares", "list_path"):
            raise PermissionError("operation must be one of: list_shares, list_path")
        username = params.get("username")
        password = params.get("password")
        domain = params.get("domain", "")
        smbclient_bin = shutil.which("smbclient")

        if smbclient_bin:
            auth_arg = "-N"
            if username is not None and password is not None:
                username = self._require_param(params, "username")
                if not isinstance(password, str):
                    raise PermissionError("Invalid parameter: password")
                if domain:
                    domain = self._require_param(params, "domain")
                    username = "%s\\%s" % (domain, username)
                auth_arg = "%s%%%s" % (username, password)
                if operation == "list_shares":
                    cmd = [smbclient_bin, "-L", "//%s" % target, "-U", auth_arg]
                else:
                    share = self._require_param(params, "share")
                    path = str(params.get("path", "\\")).replace("'", "")
                    cmd = [smbclient_bin, "//%s/%s" % (target, share), "-U", auth_arg, "-c", "ls %s" % path]
            else:
                if operation == "list_shares":
                    cmd = [smbclient_bin, "-L", "//%s" % target, "-N"]
                else:
                    share = self._require_param(params, "share")
                    path = str(params.get("path", "\\")).replace("'", "")
                    cmd = [smbclient_bin, "//%s/%s" % (target, share), "-N", "-c", "ls %s" % path]
            return self._exec_command_structured(cmd, timeout)

        if self._is_windows():
            if operation == "list_shares":
                return self._exec_command_structured(["net", "view", "\\\\%s" % target], timeout)
            share = self._require_param(params, "share")
            sub_path = str(params.get("path", "")).strip().lstrip("\\/")
            unc = "\\\\%s\\%s" % (target, share)
            if sub_path:
                unc = unc + "\\" + sub_path
            return self._exec_command_structured(["cmd", "/c", "dir", unc], timeout)

        raise PermissionError("smb_client requires smbclient (or Windows net view/cmd).")

    def _network_port_scan(self, params, timeout):
        del timeout
        target = self._require_param(params, "target")
        ports = params.get("ports", [])
        if not isinstance(ports, list) or len(ports) == 0:
            raise PermissionError("ports must be a non-empty integer list.")
        if len(ports) > 64:
            raise PermissionError("ports list exceeds maximum of 64 entries.")

        normalized_ports = []
        for port in ports:
            try:
                p = int(port)
            except Exception:
                raise PermissionError("ports list must contain integers.")
            if p < 1 or p > 65535:
                raise PermissionError("port values must be in range 1..65535.")
            normalized_ports.append(p)

        opened = []
        closed = []
        for port in sorted(set(normalized_ports)):
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(1.0)
            try:
                result = sock.connect_ex((target, port))
                if result == 0:
                    opened.append(port)
                else:
                    closed.append(port)
            except Exception:
                closed.append(port)
            finally:
                sock.close()

        out = {"target": target, "open_ports": opened, "closed_ports": closed}
        return out, json.dumps(out, sort_keys=True)

    def _network_dns_tunnel_score(self, params, timeout):
        del timeout
        query_names = params.get("query_names", [])
        if not isinstance(query_names, list) or len(query_names) == 0:
            raise PermissionError("query_names must be a non-empty list.")

        total_entropy = 0.0
        long_labels = 0
        high_entropy_labels = 0
        analyzed = []

        for q in query_names:
            if not isinstance(q, str):
                continue
            label = q.strip().lower()
            if len(label) == 0:
                continue
            entropy = self._shannon_entropy(label)
            total_entropy += entropy
            if len(label) > 45:
                long_labels += 1
            if entropy > 3.8:
                high_entropy_labels += 1
            analyzed.append({"query": label, "entropy": round(entropy, 4), "length": len(label)})

        if len(analyzed) == 0:
            raise PermissionError("query_names did not include valid string entries.")

        avg_entropy = total_entropy / float(len(analyzed))
        score = min(
            100,
            int((avg_entropy * 15) + (high_entropy_labels * 10) + (long_labels * 5)),
        )
        result = {
            "total_queries": len(analyzed),
            "avg_entropy": round(avg_entropy, 4),
            "high_entropy_count": high_entropy_labels,
            "long_label_count": long_labels,
            "dns_tunnel_score": score,
            "samples": analyzed[:50],
        }
        return result, json.dumps(result, sort_keys=True)

    def _network_legacy_protocol_exposure_map(self, params, timeout):
        del params
        self._block_if_not_windows("legacy_protocol_exposure_map")
        checks = {}
        raws = []
        commands = {
            "smb1_server": ["reg", "query", r"HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters", "/v", "SMB1"],
            "ntlmv1_policy": ["reg", "query", r"HKLM\SYSTEM\CurrentControlSet\Control\Lsa", "/v", "LmCompatibilityLevel"],
            "ldap_signing": ["reg", "query", r"HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters", "/v", "LDAPServerIntegrity"],
        }
        for key, cmd in commands.items():
            result, raw = self._exec_command_structured(cmd, timeout)
            checks[key] = result
            raws.append(raw)
        return {"checks": checks}, "\n".join(raws)

    def _network_honeypot_early_warning(self, params, timeout):
        del timeout
        sensor_id = self._require_param(params, "sensor_id")
        event_count = self._require_int_param(params, "event_count", minimum=0, maximum=100000) if "event_count" in params else 0
        recent_minutes = self._require_int_param(params, "recent_minutes", minimum=1, maximum=1440) if "recent_minutes" in params else 15

        score = min(100, int((event_count * 8) + (30 if recent_minutes <= 15 and event_count > 0 else 0)))
        severity = "LOW"
        if score >= 70:
            severity = "HIGH"
        elif score >= 40:
            severity = "MEDIUM"
        result = {
            "sensor_id": sensor_id,
            "event_count": event_count,
            "recent_minutes": recent_minutes,
            "early_warning_score": score,
            "severity": severity,
        }
        return result, json.dumps(result, sort_keys=True)

    def _network_deception_signal_fuser(self, params, timeout):
        del timeout
        window_minutes = self._require_int_param(params, "window_minutes", minimum=1, maximum=1440) if "window_minutes" in params else 60
        signals = params.get("signals", [])
        if not isinstance(signals, list):
            raise PermissionError("signals must be a list.")
        normalized = []
        for signal in signals:
            if not isinstance(signal, str):
                continue
            val = signal.strip().lower()
            if val:
                normalized.append(val)
        weights = {
            "honeypot_trip": 45,
            "honeytoken_access": 35,
            "decoy_share_access": 25,
            "deception_artifact_access": 20,
        }
        score = 0
        matched = []
        for signal in normalized:
            if signal in weights:
                score += weights[signal]
                matched.append(signal)
        if window_minutes <= 15 and score > 0:
            score += 10
        score = min(100, score)

        severity = "LOW"
        if score >= 75:
            severity = "CRITICAL"
        elif score >= 50:
            severity = "HIGH"
        elif score >= 25:
            severity = "MEDIUM"
        result = {
            "window_minutes": window_minutes,
            "matched_signals": sorted(set(matched)),
            "fusion_score": score,
            "severity": severity,
        }
        return result, json.dumps(result, sort_keys=True)

    def _shannon_entropy(self, value):
        if not value:
            return 0.0
        length = float(len(value))
        freq = {}
        for ch in value:
            freq[ch] = freq.get(ch, 0) + 1
        entropy = 0.0
        for count in freq.values():
            p = count / length
            entropy -= p * math.log(p, 2)
        return entropy

    def _validate_remote_target(self, target_host):
        if not self.enable_remote_wmi_collection:
            raise PermissionError("Remote endpoint collection is disabled by policy.")
        if len(self.remote_wmi_allowed_targets) > 0 and target_host not in self.remote_wmi_allowed_targets:
            raise PermissionError("Remote target is not allowlisted.")

    def _parse_csv_list(self, value):
        if isinstance(value, list):
            return [str(x).strip() for x in value if str(x).strip()]
        if not isinstance(value, str):
            return []
        return [x.strip() for x in value.split(",") if x.strip()]

    def _get_dynamic_tools_file(self):
        configured = self.config.get("hybrid_dynamic_tools_file")
        if isinstance(configured, str) and configured.strip():
            return configured.strip()
        root = self.config.get("plugin_data_path", ".")
        return os.path.join(root, "hybrid_dynamic_tools.json")

    def _load_dynamic_tools(self):
        path = self.dynamic_tools_file
        if not path or not os.path.isfile(path):
            return []
        try:
            with open(path, "r", encoding="utf-8") as handle:
                payload = json.load(handle)
        except Exception:
            return []
        if not isinstance(payload, list):
            return []
        tools = []
        for item in payload:
            if not isinstance(item, dict):
                continue
            if str(item.get("execution_mode", "executable")) != "executable":
                continue
            command = item.get("command")
            action = item.get("action")
            category = str(item.get("category", "")).upper()
            if (
                not isinstance(command, list)
                or len(command) == 0
                or not isinstance(action, str)
                or not isinstance(item.get("tool_id"), str)
                or category not in self.ALLOWED_CATEGORIES
            ):
                continue
            tools.append(
                {
                    "tool_id": str(item["tool_id"]),
                    "name": str(item.get("name", item["tool_id"])),
                    "category": category,
                    "action": action,
                    "execution_mode": "executable",
                    "requires_approval": bool(item.get("requires_approval", True)),
                    "risk": str(item.get("risk", "HIGH")).upper(),
                    "description": str(item.get("description", "Dynamic executable tool.")),
                    "triggers": item.get("triggers", []),
                    "parameters_schema": item.get("parameters_schema", {}),
                    "command": [str(x) for x in command],
                }
            )
        return tools

    def _save_dynamic_tools(self, tools):
        path = self.dynamic_tools_file
        parent = os.path.dirname(path)
        if parent and not os.path.isdir(parent):
            os.makedirs(parent)
        with open(path, "w", encoding="utf-8") as handle:
            json.dump(tools, handle, indent=2, sort_keys=True)

    def _refresh_dynamic_tools(self):
        self.dynamic_tools = self._load_dynamic_tools()
        action_map = {}
        category_map = {}
        for tool in self.dynamic_tools:
            action = tool.get("action")
            category = tool.get("category")
            if not isinstance(action, str) or not isinstance(category, str):
                continue
            action_map[action] = tool
            category_map.setdefault(category, set()).add(action)
        self.dynamic_tool_actions = action_map
        self.dynamic_allowed_actions = category_map

    def _get_artifact_dir(self):
        root = self.config.get("plugin_data_path", ".")
        artifact_dir = os.path.join(root, "hybrid_artifacts")
        if not os.path.isdir(artifact_dir):
            os.makedirs(artifact_dir)
        return artifact_dir

    def _build_remote_snapshot_script(self, target_host, user_ref, password, pid, remote_path, local_path):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_remote_path = self._ps_quote(remote_path)
        quoted_local_path = self._ps_quote(local_path)
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([int]$TargetPid,[string]$DumpPath);"
            "rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump $TargetPid $DumpPath full;"
            "if(!(Test-Path $DumpPath)){throw 'Remote dump file not created';}"
            "} -ArgumentList %d,'%s';"
            "$drive='HZ' + [Guid]::NewGuid().ToString('N').Substring(0,6);"
            "New-PSDrive -Name $drive -PSProvider FileSystem -Root ('\\\\%s\\c$') -Credential $cred -ErrorAction Stop | Out-Null;"
            "Copy-Item -Path ($drive + ':\\Windows\\Temp\\%s') -Destination '%s' -Force;"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {param([string]$DumpPath) Remove-Item -Path $DumpPath -Force -ErrorAction SilentlyContinue} -ArgumentList '%s';"
            "Remove-PSDrive -Name $drive -Force;"
            "Write-Output 'SNAPSHOT_OK';"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            pid,
            quoted_remote_path,
            quoted_target,
            os.path.basename(remote_path),
            quoted_local_path,
            quoted_target,
            quoted_remote_path,
        )

    def _build_remote_snapshot_by_name_script(self, target_host, user_ref, password, process_name, remote_path, local_path):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_proc = self._ps_quote(process_name)
        quoted_remote_path = self._ps_quote(remote_path)
        quoted_local_path = self._ps_quote(local_path)
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "$pidValue = Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([string]$Name,[string]$DumpPath);"
            "$p=Get-Process -Name $Name -ErrorAction SilentlyContinue | Select-Object -First 1;"
            "if(-not $p){throw 'Process not found'};"
            "rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump $p.Id $DumpPath full;"
            "if(!(Test-Path $DumpPath)){throw 'Remote dump file not created'};"
            "$p.Id;"
            "} -ArgumentList '%s','%s';"
            "$drive='HZ' + [Guid]::NewGuid().ToString('N').Substring(0,6);"
            "New-PSDrive -Name $drive -PSProvider FileSystem -Root ('\\\\%s\\c$') -Credential $cred -ErrorAction Stop | Out-Null;"
            "Copy-Item -Path ($drive + ':\\Windows\\Temp\\%s') -Destination '%s' -Force;"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {param([string]$DumpPath) Remove-Item -Path $DumpPath -Force -ErrorAction SilentlyContinue} -ArgumentList '%s';"
            "Remove-PSDrive -Name $drive -Force;"
            "Write-Output ('SNAPSHOT_OK PID=' + $pidValue);"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_proc,
            quoted_remote_path,
            quoted_target,
            os.path.basename(remote_path),
            quoted_local_path,
            quoted_target,
            quoted_remote_path,
        )

    def _build_remote_list_script(self, target_host, user_ref, password, remote_path, max_items, recursive):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_remote = self._ps_quote(remote_path)
        recurse_flag = "$true" if recursive else "$false"
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "$drive='HZ' + [Guid]::NewGuid().ToString('N').Substring(0,6);"
            "New-PSDrive -Name $drive -PSProvider FileSystem -Root ('\\\\%s\\c$') -Credential $cred -ErrorAction Stop | Out-Null;"
            "$p='%s'.Replace('C:\\\\',$drive + ':\\\\').Replace('C:/',$drive + ':/');"
            "$items=Get-ChildItem -Path $p -Force -ErrorAction Stop -Recurse:%s | Select-Object -First %d;"
            "$items | Select-Object FullName,Name,Length,Mode,LastWriteTime,PSIsContainer | ConvertTo-Json -Depth 4;"
            "Remove-PSDrive -Name $drive -Force;"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_remote,
            recurse_flag,
            max_items,
        )

    def _build_remote_collect_script(self, target_host, user_ref, password, remote_path, local_path):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_remote = self._ps_quote(remote_path)
        quoted_local = self._ps_quote(local_path)
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "$drive='HZ' + [Guid]::NewGuid().ToString('N').Substring(0,6);"
            "New-PSDrive -Name $drive -PSProvider FileSystem -Root ('\\\\%s\\c$') -Credential $cred -ErrorAction Stop | Out-Null;"
            "$src='%s'.Replace('C:\\\\',$drive + ':\\\\').Replace('C:/',$drive + ':/');"
            "Copy-Item -Path $src -Destination '%s' -Force -ErrorAction Stop;"
            "Remove-PSDrive -Name $drive -Force;"
            "Write-Output 'COLLECT_OK';"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_remote,
            quoted_local,
        )

    def _build_remote_command_script(self, target_host, user_ref, password, exe, args):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_exe = self._ps_quote(exe)
        arg_blob = ",".join(["'%s'" % self._ps_quote(x) for x in args])
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "$argsList=@(%s);"
            "$result=Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([string]$Exe,[string[]]$Args);"
            "& $Exe @Args 2>&1 | Out-String"
            "} -ArgumentList '%s',$argsList;"
            "Write-Output $result;"
        ) % (
            quoted_pass,
            quoted_user,
            arg_blob,
            quoted_target,
            quoted_exe,
        )

    def _build_remote_config_update_script(self, target_host, user_ref, password, remote_path, content, backup):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_path = self._ps_quote(remote_path)
        encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
        quoted_encoded = self._ps_quote(encoded)
        backup_flag = "$true" if backup else "$false"
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([string]$Path,[string]$Encoded,[bool]$Backup);"
            "$content=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Encoded));"
            "if($Backup -and (Test-Path $Path)){Copy-Item $Path ($Path + '.bak_' + (Get-Date -Format 'yyyyMMddHHmmss')) -Force;};"
            "Set-Content -Path $Path -Value $content -Encoding UTF8 -Force;"
            "Write-Output 'CONFIG_UPDATED';"
            "} -ArgumentList '%s','%s',%s;"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_path,
            quoted_encoded,
            backup_flag,
        )

    def _build_remote_service_restart_script(self, target_host, user_ref, password, service_name):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_service = self._ps_quote(service_name)
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([string]$Name);"
            "Restart-Service -Name $Name -Force -ErrorAction Stop;"
            "Get-Service -Name $Name | Select-Object Name,Status,StartType | ConvertTo-Json -Depth 3;"
            "} -ArgumentList '%s';"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_service,
        )

    def _build_remote_process_restart_script(self, target_host, user_ref, password, process_name, executable_path, arguments):
        quoted_user = self._ps_quote(user_ref)
        quoted_pass = self._ps_quote(password)
        quoted_target = self._ps_quote(target_host)
        quoted_proc = self._ps_quote(process_name)
        quoted_exe = self._ps_quote(executable_path)
        args_blob = ",".join(["'%s'" % self._ps_quote(x) for x in arguments])
        return (
            "$sec=ConvertTo-SecureString '%s' -AsPlainText -Force;"
            "$cred=New-Object System.Management.Automation.PSCredential('%s',$sec);"
            "Invoke-Command -ComputerName '%s' -Credential $cred -ScriptBlock {"
            "param([string]$Proc,[string]$Exe,[string[]]$Args);"
            "Get-Process -Name $Proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue;"
            "Start-Process -FilePath $Exe -ArgumentList $Args;"
            "Write-Output 'PROCESS_RESTARTED';"
            "} -ArgumentList '%s','%s',@(%s);"
        ) % (
            quoted_pass,
            quoted_user,
            quoted_target,
            quoted_proc,
            quoted_exe,
            args_blob,
        )


    def _ps_quote(self, value):
        return str(value).replace("'", "''")

    def _require_remote_path(self, params, key):
        if key not in params:
            raise PermissionError("Missing parameter: %s" % key)
        value = params[key]
        if not isinstance(value, str):
            value = str(value)
        if len(value) == 0 or len(value) > 512:
            raise PermissionError("Invalid remote path length.")
        if not re.match(r"^C:[\\/]", value):
            raise PermissionError("Remote path must be on C: drive (e.g. C:\\...).")
        if not re.match(r"^[A-Za-z0-9._\\/: \-\(\)]+$", value):
            raise PermissionError("Remote path contains unsupported characters.")
        return value

    def _validate_remote_file_target(self, target_host):
        if len(self.remote_file_allowed_targets) > 0 and target_host not in self.remote_file_allowed_targets:
            raise PermissionError("Remote file target is not allowlisted.")

    def _validate_remote_config_target(self, target_host):
        if len(self.remote_config_allowed_targets) > 0 and target_host not in self.remote_config_allowed_targets:
            raise PermissionError("Remote config target is not allowlisted.")

    def _safe_json_loads(self, value):
        try:
            return json.loads(value)
        except Exception:
            return []

    def _extract_first_int(self, text):
        if not isinstance(text, str):
            return None
        match = re.search(r"(\d+)", text)
        if not match:
            return None
        try:
            return int(match.group(1))
        except Exception:
            return None

    def _containment_disable_user(self, params, timeout):
        self._block_if_not_windows("disable_user")
        user = self._require_param(params, "username")
        return self._exec_command_structured(["net", "user", user, "/active:no"], timeout)

    def _containment_kill_process(self, params, timeout):
        pid = self._require_int_param(params, "pid", minimum=1, maximum=2147483647)
        if os.name == "nt":
            # Use OpenProcess + TerminateProcess via the Windows API directly,
            # avoiding a taskkill child process.
            import ctypes
            PROCESS_TERMINATE = 0x0001
            kernel32 = ctypes.windll.kernel32
            kernel32.OpenProcess.restype = ctypes.c_void_p
            kernel32.OpenProcess.argtypes = [ctypes.c_uint, ctypes.c_bool, ctypes.c_uint]
            kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
            kernel32.CloseHandle.argtypes = [ctypes.c_void_p]

            handle = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
            if not handle:
                err = ctypes.get_last_error()
                out = "OpenProcess failed for PID %d (Windows error %d)" % (pid, err)
                return ({"command": "OpenProcess/TerminateProcess", "stdout": "", "stderr": out, "exit_code": err}, out)
            try:
                ok = kernel32.TerminateProcess(handle, 1)
                if ok:
                    out = "Process %d terminated via TerminateProcess." % pid
                    return ({"command": "OpenProcess/TerminateProcess", "stdout": out, "stderr": "", "exit_code": 0}, out)
                else:
                    err = ctypes.get_last_error()
                    out = "TerminateProcess failed for PID %d (Windows error %d)" % (pid, err)
                    return ({"command": "OpenProcess/TerminateProcess", "stdout": "", "stderr": out, "exit_code": err}, out)
            finally:
                kernel32.CloseHandle(handle)
        else:
            return self._exec_command_structured(["kill", "-9", str(pid)], timeout)

    def _containment_stop_service(self, params, timeout):
        self._block_if_not_windows("stop_service")
        name = self._require_param(params, "service_name")
        return self._exec_command_structured(["sc", "stop", name], timeout)

    def _containment_add_firewall_block_rule(self, params, timeout):
        self._block_if_not_windows("add_firewall_block_rule")
        rule_name = self._require_param(params, "rule_name")
        remote_ip = self._require_param(params, "remote_ip")
        cmd = [
            "netsh",
            "advfirewall",
            "firewall",
            "add",
            "rule",
            "name=%s" % rule_name,
            "dir=in",
            "action=block",
            "remoteip=%s" % remote_ip,
        ]
        return self._exec_command_structured(cmd, timeout)

    def _containment_host_isolation(self, params, timeout):
        del params
        del timeout
        raise PermissionError("host_isolation is not supported by this node policy.")

    def _containment_logoff_user(self, params, timeout):
        """Force-log-off a named user's interactive sessions as a quarantine action.

        Parameters
        ----------
        username : str
            The local or domain account name to log off (e.g. ``"jsmith"`` or
            ``"DOMAIN\\\\jsmith"``).  On Windows every interactive session
            owned by that account is terminated via WTSLogoffSession (wtsapi32).
            On Linux the user's login session is terminated via loginctl or pkill.
        """
        username = self._require_param(params, "username")

        if os.name == "nt":
            # Use WTSEnumerateSessions + WTSQuerySessionInformation + WTSLogoffSession
            # directly via the Windows API — no child process required.
            import ctypes
            import ctypes.wintypes

            wts = ctypes.windll.wtsapi32
            WTS_CURRENT_SERVER_HANDLE = ctypes.c_void_p(None)
            WTSUserName = 5  # WTSInfoClass enum value

            class WTS_SESSION_INFO(ctypes.Structure):
                _fields_ = [
                    ("SessionId",       ctypes.wintypes.DWORD),
                    ("pWinStationName", ctypes.c_wchar_p),
                    ("State",           ctypes.c_int),
                ]

            pSessions = ctypes.POINTER(WTS_SESSION_INFO)()
            count = ctypes.wintypes.DWORD(0)

            if not wts.WTSEnumerateSessionsW(
                WTS_CURRENT_SERVER_HANDLE, 0, 1,
                ctypes.byref(pSessions), ctypes.byref(count)
            ):
                err = ctypes.get_last_error()
                msg = "WTSEnumerateSessionsW failed (Windows error %d)" % err
                return ({"command": "WTSEnumerateSessions/WTSLogoffSession", "stdout": "", "stderr": msg, "exit_code": err}, msg)

            logged_off = []
            try:
                for i in range(count.value):
                    sid = pSessions[i].SessionId
                    pBuf = ctypes.c_wchar_p()
                    bufLen = ctypes.wintypes.DWORD(0)
                    if wts.WTSQuerySessionInformationW(
                        WTS_CURRENT_SERVER_HANDLE, sid, WTSUserName,
                        ctypes.byref(pBuf), ctypes.byref(bufLen)
                    ):
                        session_user = pBuf.value or ""
                        wts.WTSFreeMemory(pBuf)
                        if session_user.lower() == username.lower():
                            wts.WTSLogoffSession(WTS_CURRENT_SERVER_HANDLE, sid, True)
                            logged_off.append(sid)
            finally:
                wts.WTSFreeMemory(pSessions)

            if logged_off:
                msg = "Logged off %d session(s) for '%s': %s" % (len(logged_off), username, logged_off)
            else:
                msg = "No active sessions found for user '%s'." % username
            return ({"command": "WTSEnumerateSessions/WTSLogoffSession", "stdout": msg, "stderr": "", "exit_code": 0}, msg)

        else:
            # On Linux try loginctl first (systemd), then fall back to pkill.
            result = self._exec_command_structured(
                ["loginctl", "terminate-user", username], timeout
            )
            exit_code = result[0].get("exit_code", 1) if isinstance(result, tuple) else 1
            if exit_code == 0:
                return result
            return self._exec_command_structured(
                ["pkill", "-KILL", "-u", username], timeout
            )

    def _log_audit_event(self, task, response, duration_ms):
        if not isinstance(task, dict):
            task = {}
        event = {
            "group_id": task.get("group_id"),
            "case_id": task.get("case_id"),
            "category": task.get("category"),
            "action": task.get("action"),
            "priority": task.get("priority"),
            "requires_approval": task.get("requires_approval"),
            "timeout": task.get("timeout"),
            "request": self._sanitize_for_audit(task.get("parameters", {})),
            "timestamp": int(time.time()),
            "execution_duration_ms": duration_ms,
            "output_hash": response.get("raw_output_hash"),
            "exit_code": response.get("output", {}).get("exit_code"),
            "status": response.get("status"),
            "error": response.get("error"),
        }
        self.logger.info("HYBRID_TASK_AUDIT %s" % json.dumps(event, sort_keys=True))

    def _sanitize_for_audit(self, value, depth=0):
        if depth > 4:
            return "<max_depth>"
        if isinstance(value, dict):
            redacted = {}
            for key, val in value.items():
                k = str(key)
                lk = k.lower()
                if any(s in lk for s in ("password", "secret", "token", "private_key", "signature", "base64")):
                    redacted[k] = "<redacted>"
                else:
                    redacted[k] = self._sanitize_for_audit(val, depth + 1)
            return redacted
        if isinstance(value, list):
            return [self._sanitize_for_audit(x, depth + 1) for x in value[:100]]
        if isinstance(value, str):
            if len(value) > 512:
                return value[:512] + "...<truncated>"
            return value
        if isinstance(value, (int, float, bool)) or value is None:
            return value
        return str(value)

    def _canonical_task_bytes(self, task):
        return json.dumps(task, sort_keys=True, separators=(",", ":")).encode("utf-8")

    def _load_public_key(self, key_path):
        if not key_path:
            if self.enforce_signed_tasks:
                self.logger.warning("Task signature verification is enabled but no public key path is configured.")
            return None
        if not os.path.exists(key_path):
            if self.enforce_signed_tasks:
                self.logger.warning("Task signature public key path not found: %s", key_path)
            return None
        with open(key_path, "rb") as handle:
            data = handle.read()
        try:
            return serialization.load_pem_public_key(data)
        except Exception:
            pass
        try:
            return serialization.load_ssh_public_key(data)
        except Exception:
            pass
        try:
            cert = x509.load_pem_x509_certificate(data)
            return cert.public_key()
        except Exception:
            pass
        if self.enforce_signed_tasks:
            self.logger.warning("Unable to parse task signing public key/certificate at path: %s", key_path)
        return None

    def _sha256(self, value):
        if value is None:
            value = ""
        if not isinstance(value, str):
            value = json.dumps(value, sort_keys=True)
        return hashlib.sha256(value.encode("utf-8")).hexdigest()

    def _to_bool(self, value):
        if isinstance(value, bool):
            return value
        if isinstance(value, str):
            return value.strip().lower() in ("1", "true", "yes", "on")
        return bool(value)

    # ------------------------------------------------------------------
    # DEPLOY category handlers
    # ------------------------------------------------------------------

    def _deploy_hawk_soard(self, params, timeout):
        """Deploy hawk-soard to a remote Windows host via WinRM.

        Required parameters:
            target_host  (str)  Hostname or IP of the destination host.
            username     (str)  WinRM credential user.
            password     (str)  WinRM credential password.

        Optional parameters:
            domain       (str)  Domain for the WinRM credential (DOMAIN\\user).
            hawk_server  (str)  HAWK platform URL for the remote agent config.
                                Defaults to the local agent's own 'server' config value.
            access_token (str)  Service-account access_token for the remote agent.
                                Defaults to the local agent's own 'access_token' config value.
            secret_key   (str)  Service-account secret_key for the remote agent.
                                Defaults to the local agent's own 'secret_key' config value.
            install_dir  (str)  Install path on remote host (default: C:\\Program Files\\HAWK\\SOAR).
            service_name (str)  Windows service name (default: HawkSoard).
            source_path  (str)  Local path to a cached NSIS installer to push to the remote host.
                                Auto-detected from the agent install directory if not supplied.
            source_url   (str)  URL to download hawk-soard from on the remote host.
                                Only used when no local installer is found/provided.
                                Defaults to the HAWK download server.
            insecure     (bool) Write insecure=true in the remote agent config.
        """
        self._block_if_not_windows("deploy_hawk_soard")
        if not self.enable_remote_deploy:
            raise PermissionError("Remote deployment is disabled by policy (enable_remote_deploy=false).")

        target_host = self._require_param(params, "target_host")
        if (
            len(self.remote_deploy_allowed_targets) > 0
            and target_host not in self.remote_deploy_allowed_targets
        ):
            raise PermissionError("Remote deploy target is not in the allowlist.")

        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or not password:
            raise PermissionError("Missing parameter: password")

        domain = params.get("domain", "")
        user_ref = ("%s\\%s" % (domain, username)) if domain else username

        # Credentials and server: use caller-supplied values, falling back to the
        # local agent's own running configuration so callers only need to supply
        # WinRM credentials for a same-tenant deployment.
        hawk_server  = params.get("hawk_server")  or self.config.get("server",       "")
        access_token = params.get("access_token") or self.config.get("access_token", "")
        secret_key   = params.get("secret_key")   or self.config.get("secret_key",   "")
        insecure     = bool(params.get("insecure", self.config.get("insecure", False)))

        install_dir  = params.get("install_dir")
        service_name = params.get("service_name")
        source_url   = params.get("source_url")

        from hawkSoar.security.remote_deploy import build_deploy_script, find_local_installer

        # Prefer pushing the locally cached NSIS installer (kept in the agent's
        # install directory by the installer itself) over having the remote host
        # download from the internet.  The caller can override with source_path
        # or source_url as needed.
        source_path = params.get("source_path") or find_local_installer(self.config)
        if source_path:
            self.logger.info(
                "_deploy_hawk_soard: will push local installer %s to %s"
                % (source_path, target_host)
            )

        script = build_deploy_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            hawk_server=hawk_server,
            access_token=access_token,
            secret_key=secret_key,
            install_dir=install_dir,
            service_name=service_name,
            source_url=source_url,
            source_path=source_path,
            insecure=insecure,
            ps_quote_fn=self._ps_quote,
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )

        raw = (out or "") + ("\n" + err if err else "")
        result = {
            "target_host": target_host,
            "exit_code": code,
            "stdout": out or "",
            "stderr": err or "",
        }

        # Attempt to parse the JSON status blob emitted by the remote ScriptBlock.
        if out:
            try:
                import json as _json
                status_blob = _json.loads(out.strip())
                result.update(status_blob)
            except Exception:
                pass

        return result, raw

    def _verify_hawk_soard(self, params, timeout):
        """Check the hawk-soard service status on a remote Windows host via WinRM.

        Required parameters:
            target_host  (str)  Hostname or IP of the destination host.
            username     (str)  WinRM credential user.
            password     (str)  WinRM credential password.

        Optional parameters:
            domain       (str)  Domain for the WinRM credential.
            service_name (str)  Windows service name (default: HawkSoard).
        """
        self._block_if_not_windows("verify_hawk_soard")
        if not self.enable_remote_deploy:
            raise PermissionError("Remote deployment is disabled by policy (enable_remote_deploy=false).")

        target_host = self._require_param(params, "target_host")
        if (
            len(self.remote_deploy_allowed_targets) > 0
            and target_host not in self.remote_deploy_allowed_targets
        ):
            raise PermissionError("Remote deploy target is not in the allowlist.")

        username = self._require_param(params, "username")
        password = params.get("password")
        if not isinstance(password, str) or not password:
            raise PermissionError("Missing parameter: password")

        domain = params.get("domain", "")
        user_ref = ("%s\\%s" % (domain, username)) if domain else username
        service_name = params.get("service_name")

        from hawkSoar.security.remote_deploy import build_verify_script

        script = build_verify_script(
            target_host=target_host,
            user_ref=user_ref,
            password=password,
            service_name=service_name,
            ps_quote_fn=self._ps_quote,
        )

        code, out, err = self.command_runner(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            timeout,
        )

        raw = (out or "") + ("\n" + err if err else "")
        result = {
            "target_host": target_host,
            "exit_code": code,
            "stdout": out or "",
            "stderr": err or "",
        }

        if out:
            try:
                import json as _json
                result.update(_json.loads(out.strip()))
            except Exception:
                pass

        return result, raw
