Загрузка данных


# -*- coding: utf-8 -*-

import argparse
import base64
import csv
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional, List, Tuple, Set, Dict


def run_powershell(command):
    """
    Запускает PowerShell-команду через -EncodedCommand.
    Возвращает stdout как строку.
    """
    # PowerShell -EncodedCommand требует UTF-16LE в Base64
    encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
    
    result = subprocess.run(
        [
            "powershell.exe",
            "-NoProfile",
            "-NonInteractive",
            "-EncodedCommand",
            encoded,
        ],
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        check=False,
    )
    
    if result.returncode != 0:
        error_text = result.stderr.strip() or result.stdout.strip()
        raise RuntimeError("PowerShell error: {}".format(error_text))
    
    return result.stdout


def normalize_computer_name(name):
    """
    Приводит имя компьютера к короткому виду:
    PC001.contoso.com -> PC001
    """
    if not name:
        return None
    
    name = name.strip()
    name = name.split(".")[0].strip().upper()
    
    return name if name else None


def safe_file_name(name):
    """
    Делает имя файла безопасным для Windows.
    """
    if not name:
        return "Unknown"
    
    # Заменяем недопустимые символы на подчёркивания
    return re.sub(r'[\\/:*?"<>|]', "_", name).strip() or "Unknown"


def escape_single_quotes_ps(value):
    """
    Экранирует одинарные кавычки для PowerShell.
    """
    return value.replace("'", "''")


def ldap_escape(value):
    """
    Минимальное экранирование для LDAP-фильтра.
    """
    value = value.replace("\\", "\\5c")
    value = value.replace("*", "\\2a")
    value = value.replace("(", "\\28")
    value = value.replace(")", "\\29")
    value = value.replace("\0", "\\00")
    
    return value


def get_ad_groups(groups_search_base):
    """
    Получает группы из указанного контейнера/OU в AD.
    
    Возвращает список кортежей: [(GroupName, GroupDN), ...]
    """
    search_base = escape_single_quotes_ps(groups_search_base)
    
    # PowerShell команда для получения групп
    ps_command = """
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Import-Module ActiveDirectory -ErrorAction Stop

Get-ADGroup -Filter * -SearchBase '{search_base}' |
    ForEach-Object {{ "$($_.Name)|$($_.DistinguishedName)" }}
""".format(search_base=search_base)
    
    output = run_powershell(ps_command)
    
    groups = []
    
    for line in output.splitlines():
        line = line.strip()
        
        if not line or "|" not in line:
            continue
        
        parts = line.split("|", 1)
        if len(parts) == 2:
            group_name = parts[0].strip()
            group_dn = parts[1].strip()
            groups.append((group_name, group_dn))
    
    return groups


def get_group_computers(group_dn, include_disabled, recursive):
    """
    Получает компьютеры из группы AD.
    
    Если recursive=True, используется LDAP matching rule
    для рекурсивного участия групп.
    """
    if recursive:
        member_rule = "memberOf:1.2.840.113556.1.4.1941"
    else:
        member_rule = "memberOf"
    
    escaped_group_dn = ldap_escape(group_dn)
    
    if include_disabled:
        disabled_filter = ""
    else:
        # Исключаем отключённые компьютеры
        disabled_filter = "(!(userAccountControl:1.2.840.113556.1.4.803:=2))"
    
    ldap_filter = "(&(objectCategory=computer)(objectClass=computer)({member_rule}={escaped_group_dn}){disabled_filter})".format(
        member_rule=member_rule,
        escaped_group_dn=escaped_group_dn,
        disabled_filter=disabled_filter
    )
    
    ldap_filter_ps = escape_single_quotes_ps(ldap_filter)
    
    ps_command = """
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
Import-Module ActiveDirectory -ErrorAction Stop

Get-ADComputer -LDAPFilter '{ldap_filter}' |
    Select-Object -ExpandProperty Name
""".format(ldap_filter=ldap_filter_ps)
    
    output = run_powershell(ps_command)
    
    computers = []
    
    for line in output.splitlines():
        line = line.strip()
        if line:
            computers.append(line)
    
    return computers


def get_seen_computers(log_name, event_id, days):
    """
    Читает события через wevtutil.exe и возвращает множество
    нормализованных имён компьютеров, у которых найдено событие.
    """
    milliseconds = days * 24 * 60 * 60 * 1000
    
    xpath_query = "*[System[(EventID={event_id}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]".format(
        event_id=event_id,
        milliseconds=milliseconds
    )
    
    result = subprocess.run(
        [
            "wevtutil.exe",
            "qe",
            log_name,
            "/q:{query}".format(query=xpath_query),
            "/f:xml",
        ],
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        check=False,
    )
    
    if result.returncode != 0:
        error_text = result.stderr.strip() or result.stdout.strip()
        
        # Если событий просто нет, некоторые системы могут вернуть пустой вывод
        if not result.stdout.strip():
            return set()
        
        raise RuntimeError("wevtutil error: {}".format(error_text))
    
    output = result.stdout or ""
    
    seen = set()
    
    # Ищем тег Computer в XML-выводе событий
    # Пример: <Computer>PC001</Computer>
    for match in re.finditer(
        r"<Computer[^>]*>([^<]+)</Computer>",
        output,
        re.IGNORECASE,
    ):
        computer_name = normalize_computer_name(match.group(1))
        
        if computer_name:
            seen.add(computer_name)
    
    return seen


def main():
    parser = argparse.ArgumentParser(
        description="Мониторинг WEC: поиск компьютеров из групп AD, которые не отправляли событие 104 за N дней."
    )
    
    parser.add_argument(
        "--log-name",
        default="ForwardedEvents",
        help="Журнал событий на WEC-сервере. По умолчанию ForwardedEvents.",
    )
    
    parser.add_argument(
        "--event-id",
        type=int,
        default=104,
        help="Код события. По умолчанию 104.",
    )
    
    parser.add_argument(
        "--days",
        type=int,
        default=5,
        help="За сколько дней проверять отсутствие события. По умолчанию 5.",
    )
    
    parser.add_argument(
        "--groups-search-base",
        required=True,
        help="DistinguishedName контейнера/OU с группами. Пример: OU=WEC,OU=Groups,DC=contoso,DC=com",
    )
    
    parser.add_argument(
        "--output-folder",
        default=r"C:\WEF-Monitor\Reports",
        help="Папка для отчётов.",
    )
    
    parser.add_argument(
        "--recursive",
        action="store_true",
        help="Учитывать вложенные группы AD.",
    )
    
    parser.add_argument(
        "--include-disabled",
        action="store_true",
        help="Включать отключённые компьютеры AD. По умолчанию отключённые исключаются.",
    )
    
    args = parser.parse_args()
    
    output_folder = Path(args.output_folder)
    output_folder.mkdir(parents=True, exist_ok=True)
    
    print("Получение групп из AD...")
    
    groups = get_ad_groups(args.groups_search_base)
    
    if not groups:
        sys.exit("Не найдено ни одной группы в AD: {}".format(args.groups_search_base))
    
    print("Найдено групп: {}".format(len(groups)))
    
    print("Чтение событий WEC...")
    
    seen_computers = get_seen_computers(
        log_name=args.log_name,
        event_id=args.event_id,
        days=args.days,
    )
    
    print("Компьютеров с событием {} за {} дней: {}".format(
        args.event_id,
        args.days,
        len(seen_computers)
    ))
    
    summary = []
    
    for group_name, group_dn in groups:
        print("Обработка группы: {}".format(group_name))
        
        computers = get_group_computers(
            group_dn=group_dn,
            include_disabled=args.include_disabled,
            recursive=args.recursive,
        )
        
        computer_map = {}
        
        for computer in computers:
            normalized = normalize_computer_name(computer)
            
            if normalized and normalized not in computer_map:
                computer_map[normalized] = computer
        
        missing = []
        
        for normalized_name in sorted(computer_map.keys()):
            if normalized_name not in seen_computers:
                missing.append(computer_map[normalized_name])
        
        safe_name = safe_file_name(group_name)
        
        txt_file = output_folder / "WEC_Missing_{}_latest.txt".format(safe_name)
        csv_file = output_folder / "WEC_Missing_{}_latest.csv".format(safe_name)
        
        # TXT: только имена компьютеров
        txt_file.write_text(
            "\n".join(missing),
            encoding="utf-8",
        )
        
        # CSV: подробный отчёт по группе
        with csv_file.open(mode="w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(["Group", "Computer"])
            
            for computer in missing:
                writer.writerow([group_name, computer])
        
        summary.append({
            "Group": group_name,
            "ADComputers": len(computer_map),
            "Missing": len(missing),
            "TxtReport": str(txt_file),
            "CsvReport": str(csv_file),
        })
        
        print("  Компьютеров в группе: {}".format(len(computer_map)))
        print("  Не отправляли событие: {}".format(len(missing)))
        print("  TXT: {}".format(txt_file))
        print("  CSV: {}".format(csv_file))
        print()
    
    summary_csv = output_folder / "WEC_Missing_summary_latest.csv"
    
    with summary_csv.open(mode="w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow([
            "Group",
            "ADComputers",
            "Missing",
            "TxtReport",
            "CsvReport",
        ])
        
        for row in summary:
            writer.writerow([
                row["Group"],
                row["ADComputers"],
                row["Missing"],
                row["TxtReport"],
                row["CsvReport"],
            ])
    
    print("Сводный отчёт сохранён: {}".format(summary_csv))


if __name__ == "__main__":
    main()