Загрузка данных
# -*- 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()