Скрипты и ПО
bash4
bashфайлов: 14
Proxmox VE Cluster Autoconfig
Комплект идемпотентных bash-скриптов для автоматической сборки production-кластера Proxmox VE 8/9 с выбором модели хранилища (Ceph / FC-hybrid) и программно-определяемой сетью (SDN), построенной по образцу VMware vSphere Distributed Switch (vDS).
Показать код
#!/usr/bin/env bash
# deploy-all.sh — оркестратор полного развёртывания. Выполняет этапы в
# правильном порядке с учётом роли узла и DEPLOY_MODE. Идемпотентен —
# безопасно перезапускать. Поддерживает выборочный запуск (--only) и
# продолжение с этапа (--from).
#
# Порядок фаз:
# phase-local (на КАЖДОМ узле): 00-preflight, 10-network, 40-fc(multipath)
# phase-init (на ИНИЦИАТОРЕ): 20-cluster
# [join] (на остальных): 20-cluster
# phase-cluster(на ИНИЦИАТОРЕ): 30-ceph, 40-fc(vg/storage), 50-sdn, 60-ha
#
# Рекомендуемый сценарий:
# 1) на каждом узле: ./deploy-all.sh --phase local
# 2) на инициаторе: ./deploy-all.sh --phase init
# 3) на остальных узлах: ./deploy-all.sh --phase join
# 4) на инициаторе: ./deploy-all.sh --phase cluster
# Либо всё сразу на инициаторе с настроенным SSH: ./deploy-all.sh --phase all
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/common.sh"
CONFIG_FILE="${CONFIG_FILE:-${SCRIPT_DIR}/../config/cluster.conf}"
PHASE="all"
usage() {
cat <<EOF
Использование: $0 [--config FILE] [--phase local|init|join|cluster|all]
[--dry-run] [--yes]
--config FILE путь к cluster.conf (по умолчанию config/cluster.conf)
--phase какую фазу выполнить (см. заголовок скрипта)
--dry-run только печатать команды (экспорт DRY_RUN=1)
--yes не запрашивать подтверждений (экспорт ASSUME_YES=1)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--config) CONFIG_FILE="$2"; shift 2 ;;
--phase) PHASE="$2"; shift 2 ;;
--dry-run) export DRY_RUN=1; shift ;;
--yes) export ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Неизвестный аргумент: $1" ;;
esac
done
require_root
load_config "$CONFIG_FILE"
export CONFIG_FILE
run_step() {
local script="$1"
log "═══ Этап: ${script} ═══"
bash "${SCRIPT_DIR}/${script}" "$CONFIG_FILE"
ok "═══ Завершён: ${script} ═══"
}
phase_local() {
run_step 00-preflight.sh
run_step 10-network.sh
# multipath-часть 40 запускается на каждом узле (VG/storage внутри скрипта
# выполняется только на инициаторе — он сам это определяет).
if [[ "$DEPLOY_MODE" == "fc" || "$DEPLOY_MODE" == "hybrid" ]]; then
run_step 40-fc-storage.sh
fi
}
phase_init() { run_step 20-cluster.sh; }
phase_join() { run_step 20-cluster.sh; }
phase_cluster() {
[[ "$DEPLOY_MODE" == "ceph" || "$DEPLOY_MODE" == "hybrid" ]] && run_step 30-ceph.sh
[[ "$DEPLOY_MODE" == "fc" || "$DEPLOY_MODE" == "hybrid" ]] && run_step 40-fc-storage.sh
run_step 50-sdn.sh
run_step 60-ha.sh
}
case "$PHASE" in
local) phase_local ;;
init) phase_init ;;
join) phase_join ;;
cluster) phase_cluster ;;
all)
warn "Фаза 'all' предполагает настроенный SSH и запуск с инициатора."
phase_local
phase_init
phase_cluster
warn "НЕ забудьте выполнить фазы 'local' и 'join' на остальных узлах!"
;;
*) die "Неизвестная фаза: $PHASE" ;;
esac
ok "Оркестрация фазы '${PHASE}' завершена."
Файлы комплекта
common.sh bash00-preflight.sh bash10-network.sh bash20-cluster.sh bash30-ceph.sh bash40-fc-storage.sh bash50-sdn.sh bash60-ha.sh bashadd-ha-vm.sh bashteardown.sh bashvalidate.sh bashsdn-model.reference.yaml yamlcluster.conf.examplebashфайлов: 16
Пошаговое развёртывание: Samba AD + Postfix/Dovecot/LDAP + PMG + EAS на двух площадках
Домен AD: ad.example.com (NetBIOS EXAMPLE) · Почта: example.com ОС всех узлов: Debian 12 · Топология: Active-Active, по узлу каждой роли на площадку Периметр: PMG1/PMG2 (релей + DKIM/DMARC) · EAS: grommunio-sync · Бэкенд почты: Postfix+Dovecot, LDAP-аутентификация в AD GUI (всё FOSS): LAM Community (каталог) · Roundcube (веб-почта) · PMG web UI (антиспам/очереди)
Показать код
#!/usr/bin/env bash
# 00-base.sh — базовая подготовка узла (все 6 узлов: dc1,dc2,mx1,mx2,pmg1,pmg2)
# Идемпотентен. Запуск: sudo FQDN=dc1.ad.example.com IP=10.10.10.10 ./00-base.sh
# опц.: TZ_NAME=UTC
source "$(dirname "$0")/lib.sh"
require_root
require_env FQDN
require_env IP
TZ_NAME="${TZ_NAME:-UTC}"
valid_fqdn "$FQDN" || die "FQDN некорректен: $FQDN"
valid_ipv4 "$IP" || die "IP некорректен: $IP"
info "Узел: ${FQDN} (${IP}), TZ=${TZ_NAME}"
# ── время ──
timedatectl set-timezone "$TZ_NAME"
hostnamectl set-hostname "$FQDN"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get -y -qq dist-upgrade
apt-get -y -qq install chrony curl gnupg ca-certificates dnsutils \
rsync vim less acl attr ufw net-tools
systemctl enable --now chrony
chronyc -a makestep >/dev/null 2>&1 || warn "chronyc makestep отложен (нет источника?)"
# ── /etc/hosts: FQDN → статический IP, без 127.0.1.1 ──
SHORT="${FQDN%%.*}"
backup_once /etc/hosts
sed -i '/127\.0\.1\.1/d' /etc/hosts
ensure_line "${IP} ${FQDN} ${SHORT}" /etc/hosts
ok "Базовая подготовка ${FQDN} завершена"
Файлы комплекта
10-dc1-provision.sh bash20-dc2-join.sh bash30-directory-objects.sh bash40-mail-install.sh bash50-eas-install.sh bash60-pmg-relay.sh bash70-firewall-mx.sh bash80-lam-install.sh bash85-sysvol-sync.sh bash90-roundcube-install.sh bash95-domain-rebuild.sh bashlib.sh bashautodiscover.php phpexample.com.zoneconfig-v1.1.xmlbash
Angie Reverse Proxy для 1С (IIS)
Автоматизированное развёртывание Angie в роли защищённого reverse proxy для публикации 1С на IIS наружу через HTTPS, с веб-мониторингом (Angie Console Light) и защитой от типовых веб-атак и L7-DDoS.
Показать код
#!/usr/bin/env bash
#===============================================================================
# deploy-angie-rp.sh (v2 — публикация 1С/IIS + Console Light мониторинг)
#
# Внешний вход: TCP/443 (TLS 1.2/1.3), server_name = FQDN
# Проксирование: на upstream IP:PORT публикации 1С в IIS (по умолчанию 5743)
# Мониторинг: Angie Console Light на ОТДЕЛЬНОМ внутреннем server-блоке
# (порт по умолчанию 8443, доступ только из доверенных сетей)
# Защита: TLS hardening, security headers, L7 rate/conn-limit,
# anti-slowloris, ACL, опционально ModSecurity+CRS.
#
# Специфика 1С/IIS (аутентификация анонимная/внутри 1С):
# - WebSocket-апгрейд для тонкого клиента (map Connection)
# - длинные таймауты (отчёты/проведение/обмен) -> 1800s
# - большие тела (внешние обработки/обмен) -> client_max_body_size 512m
# - раздельные rate-зоны: щедрая для веб-клиента, отдельная для веб-сервисов
# - сохранение пути публикации (/base) и корректный proxy_redirect
#
# Поддерживаемые ОС: Debian 11/12, Ubuntu 22.04/24.04, RHEL/Rocky/Alma 8/9.
# Идемпотентность: повторный запуск безопасен (бэкап конфигов, перегенерация).
#===============================================================================
set -Eeuo pipefail
#------------------------------- Константы -------------------------------------
readonly SCRIPT_NAME="${0##*/}"
readonly LOG_FILE="/var/log/deploy-angie-rp.log"
readonly ANGIE_CONF_DIR="/etc/angie"
readonly SITE_CONF="${ANGIE_CONF_DIR}/http.d/reverse-proxy.conf"
readonly SEC_CONF="${ANGIE_CONF_DIR}/http.d/00-security.conf"
readonly CONSOLE_CONF="${ANGIE_CONF_DIR}/http.d/10-console.conf"
readonly TLS_DIR="${ANGIE_CONF_DIR}/tls"
readonly CONSOLE_ROOT="/usr/share/angie-console-light/html"
readonly BACKUP_DIR="${ANGIE_CONF_DIR}/backup-$(date +%Y%m%d-%H%M%S)"
ENABLE_MODSEC="${ENABLE_MODSEC:-ask}"
ENABLE_CONSOLE="${ENABLE_CONSOLE:-ask}"
UPDATE_SYSTEM="${UPDATE_SYSTEM:-ask}" # yes|no|ask — обновить пакеты ОС перед установкой
MANAGE_BASE_REPOS="${MANAGE_BASE_REPOS:-yes}" # yes|no — добавлять недостающие базовые репозитории ОС
RATE_RPS="${RATE_RPS:-50}"
RATE_BURST="${RATE_BURST:-100}"
CONN_PER_IP="${CONN_PER_IP:-50}"
CONSOLE_PORT="${CONSOLE_PORT:-8443}"
NONINTERACTIVE="${NONINTERACTIVE:-0}"
FQDN=""
UPSTREAM_IP=""
UPSTREAM_PORT=""
UPSTREAM_SCHEME="http"
ONEC_BASE=""
WS_PATHS="ws hs" # пути веб-сервисов 1С (через пробел): ws hs [api ...]
TRUSTED_NETS=""
CONSOLE_USER=""
CONSOLE_PASS=""
#------------------------------- Утилиты ---------------------------------------
log() { echo -e "[$(date +'%F %T')] $*" | tee -a "$LOG_FILE" >&2; }
info() { log "\033[0;34m[INFO]\033[0m $*"; }
ok() { log "\033[0;32m[ OK ]\033[0m $*"; }
warn() { log "\033[0;33m[WARN]\033[0m $*"; }
err() { log "\033[0;31m[FAIL]\033[0m $*"; }
die() { err "$*"; exit 1; }
on_err() { local ec=$?; err "Ошибка на строке ${BASH_LINENO[0]} (код $ec). Смотрите $LOG_FILE"; exit "$ec"; }
trap on_err ERR
require_root() { [[ ${EUID} -eq 0 ]] || die "Запускать от root (sudo $SCRIPT_NAME)."; }
#------------------------------- Валидаторы ------------------------------------
is_valid_fqdn() {
local f="$1"
[[ ${#f} -le 253 ]] || return 1
[[ "$f" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z]{2,63}|xn--[a-zA-Z0-9-]{2,59}[a-zA-Z0-9])$ ]]
}
is_valid_ipv4() {
local ip="$1" o
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1
IFS='.' read -r -a oct <<< "$ip"
for o in "${oct[@]}"; do
(( o >= 0 && o <= 255 )) || return 1
[[ "$o" == "0" || "$o" != 0* ]] || return 1
done
return 0
}
is_valid_port() { local p="$1"; [[ "$p" =~ ^[0-9]+$ ]] || return 1; (( p >= 1 && p <= 65535 )); }
is_valid_cidr() {
local c="$1" ip mask
if [[ "$c" == */* ]]; then
ip="${c%/*}"; mask="${c#*/}"
[[ "$mask" =~ ^[0-9]+$ ]] || return 1
(( mask >= 0 && mask <= 32 )) || return 1
else ip="$c"; fi
is_valid_ipv4 "$ip"
}
is_valid_onec_base() {
local b="$1"; [[ -z "$b" ]] && return 0
[[ "$b" =~ ^(/[A-Za-z0-9._-]+)+$ ]]
}
# Список путей веб-сервисов через пробел: каждый сегмент из [A-Za-z0-9._-], хотя бы один.
is_valid_ws_paths() {
local s="$1" p
[[ -z "${s// /}" ]] && return 1
for p in $s; do
[[ "$p" =~ ^[A-Za-z0-9._-]+$ ]] || return 1
[[ "$p" == -* ]] && return 1 # сегмент не должен начинаться с дефиса
done
return 0
}
# Собирает альтернацию для регекспа: "ws hs api" -> "ws|hs|api"
ws_paths_regex() {
local s="$1" out="" p
for p in $s; do out+="${out:+|}${p}"; done
printf '%s' "$out"
}
#------------------------------- Ввод данных -----------------------------------
prompt_inputs() {
if [[ "$NONINTERACTIVE" == "1" ]]; then
FQDN="${FQDN:-${IN_FQDN:-}}"
UPSTREAM_IP="${UPSTREAM_IP:-${IN_UPSTREAM_IP:-}}"
UPSTREAM_PORT="${UPSTREAM_PORT:-${IN_UPSTREAM_PORT:-5743}}"
UPSTREAM_SCHEME="${IN_UPSTREAM_SCHEME:-http}"
ONEC_BASE="${IN_ONEC_BASE:-}"
WS_PATHS="${IN_WS_PATHS:-ws hs}"
TRUSTED_NETS="${IN_TRUSTED_NETS:-127.0.0.1}"
[[ "$ENABLE_CONSOLE" == "ask" ]] && ENABLE_CONSOLE="${IN_ENABLE_CONSOLE:-yes}"
[[ "$ENABLE_MODSEC" == "ask" ]] && ENABLE_MODSEC="${IN_ENABLE_MODSEC:-no}"
is_valid_fqdn "$FQDN" || die "NONINTERACTIVE: некорректный IN_FQDN='$FQDN'"
is_valid_ipv4 "$UPSTREAM_IP" || die "NONINTERACTIVE: некорректный IN_UPSTREAM_IP='$UPSTREAM_IP'"
is_valid_port "$UPSTREAM_PORT" || die "NONINTERACTIVE: некорректный IN_UPSTREAM_PORT='$UPSTREAM_PORT'"
is_valid_onec_base "$ONEC_BASE"|| die "NONINTERACTIVE: некорректный IN_ONEC_BASE='$ONEC_BASE'"
is_valid_ws_paths "$WS_PATHS" || die "NONINTERACTIVE: некорректный IN_WS_PATHS='$WS_PATHS'"
return 0
fi
while :; do
read -r -p "FQDN внешнего ресурса (напр. 1c.example.kz): " FQDN
FQDN="${FQDN,,}"; FQDN="${FQDN#"${FQDN%%[![:space:]]*}"}"; FQDN="${FQDN%"${FQDN##*[![:space:]]}"}"
is_valid_fqdn "$FQDN" && break
warn "Некорректный FQDN. Пример: 1c.example.kz"
done
local sc
read -r -p "Протокол до IIS-бэкенда [http/https] (по умолчанию http): " sc
sc="${sc,,}"
case "$sc" in
https) UPSTREAM_SCHEME="https" ;;
http|"") UPSTREAM_SCHEME="http" ;;
*) warn "Неизвестно '$sc', использую http"; UPSTREAM_SCHEME="http" ;;
esac
while :; do
read -r -p "IP-адрес сервера IIS с публикацией 1С: " UPSTREAM_IP
UPSTREAM_IP="${UPSTREAM_IP//[[:space:]]/}"
is_valid_ipv4 "$UPSTREAM_IP" && break
warn "Некорректный IPv4. Пример: 10.20.30.40"
done
while :; do
read -r -p "Порт публикации 1С на IIS (по умолчанию 5743): " UPSTREAM_PORT
UPSTREAM_PORT="${UPSTREAM_PORT//[[:space:]]/}"
[[ -z "$UPSTREAM_PORT" ]] && UPSTREAM_PORT="5743"
is_valid_port "$UPSTREAM_PORT" && break
warn "Некорректный порт (1-65535)."
done
while :; do
read -r -p "Имя публикации 1С в IIS, напр. /accounting (Enter = корень /): " ONEC_BASE
ONEC_BASE="${ONEC_BASE//[[:space:]]/}"
[[ "$ONEC_BASE" == "/" ]] && ONEC_BASE=""
ONEC_BASE="${ONEC_BASE%/}"
is_valid_onec_base "$ONEC_BASE" && break
warn "Некорректный путь. Пример: /accounting"
done
# Пути веб-сервисов 1С (SOAP/HTTP) — отдельная зона лимитов и стриминг.
while :; do
read -r -p "Пути веб-сервисов 1С через пробел (Enter = 'ws hs'): " WS_PATHS
WS_PATHS="${WS_PATHS# }"; WS_PATHS="${WS_PATHS% }"
[[ -z "$WS_PATHS" ]] && WS_PATHS="ws hs"
is_valid_ws_paths "$WS_PATHS" && break
warn "Некорректно. Пример: ws hs или ws hs api (латиница/цифры/_/-, через пробел)"
done
if [[ "$ENABLE_CONSOLE" == "ask" ]]; then
local a
read -r -p "Установить веб-мониторинг Angie Console Light? [Y/n]: " a
[[ "${a,,}" == "n" || "${a,,}" == "no" ]] && ENABLE_CONSOLE="no" || ENABLE_CONSOLE="yes"
fi
if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
while :; do
read -r -p "Доверенные сети для консоли (через пробел, напр. '10.30.0.0/16 192.168.1.5'): " TRUSTED_NETS
TRUSTED_NETS="${TRUSTED_NETS# }"; TRUSTED_NETS="${TRUSTED_NETS% }"
[[ -z "$TRUSTED_NETS" ]] && { warn "Пусто нельзя — консоль не должна быть открыта всем."; continue; }
local bad=0 n
for n in $TRUSTED_NETS; do is_valid_cidr "$n" || { warn "Некорректная сеть/IP: $n"; bad=1; }; done
(( bad == 0 )) && break
done
while :; do
read -r -p "Порт внутреннего server-блока консоли (по умолчанию 8443): " CONSOLE_PORT
CONSOLE_PORT="${CONSOLE_PORT//[[:space:]]/}"
[[ -z "$CONSOLE_PORT" ]] && CONSOLE_PORT="8443"
is_valid_port "$CONSOLE_PORT" && break
warn "Некорректный порт."
done
fi
if [[ "$ENABLE_MODSEC" == "ask" ]]; then
local m
read -r -p "Установить ModSecurity + OWASP CRS (WAF)? [y/N]: " m
[[ "${m,,}" == "y" || "${m,,}" == "yes" ]] && ENABLE_MODSEC="yes" || ENABLE_MODSEC="no"
fi
echo
info "Подтвердите параметры:"
info " FQDN: $FQDN"
info " 1С/IIS backend: ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}${ONEC_BASE:-/}"
info " Веб-сервисы: $(ws_paths_regex "$WS_PATHS") (пути: ${WS_PATHS})"
info " Rate limit: ${RATE_RPS} r/s (burst ${RATE_BURST}), conn/IP ${CONN_PER_IP}"
info " Console Light: ${ENABLE_CONSOLE}$( [[ "$ENABLE_CONSOLE" == yes ]] && echo " (порт ${CONSOLE_PORT}, сети: ${TRUSTED_NETS})" )"
info " ModSecurity: ${ENABLE_MODSEC}"
local c
read -r -p "Продолжить? [Y/n]: " c
[[ "${c,,}" == "n" || "${c,,}" == "no" ]] && die "Отменено пользователем."
}
#------------------------------- Детект ОС -------------------------------------
detect_os() {
[[ -r /etc/os-release ]] || die "Не найден /etc/os-release."
# shellcheck disable=SC1091
source /etc/os-release
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
OS_VERSION_ID="${VERSION_ID:-}"
# Кодовое имя релиза (bookworm/jammy/noble...): из os-release либо lsb_release
OS_CODENAME="${VERSION_CODENAME:-}"
[[ -z "$OS_CODENAME" ]] && OS_CODENAME="$(lsb_release -cs 2>/dev/null || true)"
# Мажор EL для RHEL-семейства (8/9...)
EL_MAJOR="${OS_VERSION_ID%%.*}"
case "$OS_ID" in
debian|ubuntu) PKG="deb" ;;
rhel|centos|rocky|almalinux|fedora) PKG="rpm" ;;
*)
if [[ "$OS_LIKE" == *debian* ]]; then PKG="deb"
elif [[ "$OS_LIKE" == *rhel* || "$OS_LIKE" == *fedora* ]]; then PKG="rpm"
else die "Неподдерживаемая ОС: $OS_ID"; fi ;;
esac
ok "ОС: ${PRETTY_NAME:-$OS_ID} (пакеты: $PKG, релиз: ${OS_CODENAME:-$OS_VERSION_ID})"
}
#--------------------- Базовые репозитории дистрибутива -------------------------
# Проверяет наличие штатных репо ОС и добавляет недостающие:
# Debian: main + security + updates
# Ubuntu: main, universe, updates, security
# RHEL/*: baseos, appstream, CRB/PowerTools, EPEL
ensure_base_repos() {
[[ "$MANAGE_BASE_REPOS" == "yes" ]] || { info "Управление базовыми репо отключено (MANAGE_BASE_REPOS=no)."; return 0; }
info "Проверяю базовые репозитории дистрибутива..."
if [[ "$PKG" == "deb" ]]; then ensure_base_repos_deb; else ensure_base_repos_rpm; fi
}
ensure_base_repos_deb() {
local ts backup added=0 list sources cn
ts="$(date +%Y%m%d-%H%M%S)"
backup="/etc/apt/sources.backup-${ts}"
mkdir -p "$backup"
[[ -f /etc/apt/sources.list ]] && cp -a /etc/apt/sources.list "$backup/" 2>/dev/null || true
[[ -d /etc/apt/sources.list.d ]] && cp -a /etc/apt/sources.list.d "$backup/" 2>/dev/null || true
sources="$(grep -rhE '^\s*(deb|Types:|URIs:|Suites:|Components:)' \
/etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null || true)"
cn="${OS_CODENAME:-stable}"
list="/etc/apt/sources.list.d/90-base-added.list"
add_deb_line() { # $1 маркер, $2 строка, $3 описание
if ! echo "$sources" | grep -q -- "$1"; then
echo "$2" >> "$list"
info "Добавлен репозиторий: $3"
added=1
sources="${sources}"$'\n'"$2"
fi
}
if [[ "$OS_ID" == "ubuntu" ]]; then
local url="http://archive.ubuntu.com/ubuntu" surl="http://security.ubuntu.com/ubuntu"
add_deb_line " ${cn} .*universe" "deb ${url} ${cn} main restricted universe multiverse" "${cn} main/universe"
add_deb_line " ${cn}-updates" "deb ${url} ${cn}-updates main restricted universe multiverse" "${cn}-updates"
add_deb_line " ${cn}-security" "deb ${surl} ${cn}-security main restricted universe multiverse" "${cn}-security"
elif [[ "$OS_ID" == "debian" || "$OS_LIKE" == *debian* ]]; then
local url="http://deb.debian.org/debian" surl="http://security.debian.org/debian-security"
add_deb_line " ${cn} .*main" "deb ${url} ${cn} main contrib non-free non-free-firmware" "${cn} main"
add_deb_line " ${cn}-updates" "deb ${url} ${cn}-updates main contrib non-free non-free-firmware" "${cn}-updates"
add_deb_line "${cn}-security" "deb ${surl} ${cn}-security main contrib non-free non-free-firmware" "${cn}-security"
fi
if (( added )); then
ok "Базовые репозитории дополнены (бэкап: $backup). Обновляю индексы..."
apt-get update -y || warn "apt-get update с ошибкой — проверьте $list"
else
ok "Все необходимые базовые репозитории уже подключены."
fi
}
ensure_base_repos_rpm() {
local mgr; mgr="$(command -v dnf >/dev/null && echo dnf || echo yum)"
$mgr -y install "dnf-command(config-manager)" >/dev/null 2>&1 \
|| $mgr -y install yum-utils >/dev/null 2>&1 || true
case "$EL_MAJOR" in
9) $mgr config-manager --set-enabled crb >/dev/null 2>&1 \
&& ok "Включён репозиторий CRB." || warn "CRB включить не удалось (возможно уже включён)." ;;
8) ($mgr config-manager --set-enabled powertools >/dev/null 2>&1 \
|| $mgr config-manager --set-enabled PowerTools >/dev/null 2>&1) \
&& ok "Включён репозиторий PowerTools." || warn "PowerTools включить не удалось." ;;
*) info "EL${EL_MAJOR}: CRB/PowerTools не требуется/не определён." ;;
esac
if ! $mgr repolist enabled 2>/dev/null | grep -qi '^epel'; then
if $mgr -y install epel-release >/dev/null 2>&1; then
ok "Установлен EPEL."
elif [[ -n "$EL_MAJOR" ]]; then
$mgr -y install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${EL_MAJOR}.noarch.rpm" >/dev/null 2>&1 \
&& ok "Установлен EPEL (прямой RPM)." || warn "EPEL установить не удалось."
fi
else
ok "EPEL уже подключён."
fi
$mgr -y makecache >/dev/null 2>&1 || true
}
#--------------------- Обновление системы --------------------------------------
update_system() {
case "$UPDATE_SYSTEM" in
no) info "Обновление системы пропущено (UPDATE_SYSTEM=no)."; return 0 ;;
yes) : ;;
ask)
if [[ "$NONINTERACTIVE" == "1" ]]; then info "Обновление системы пропущено (не задано)."; return 0; fi
local a
read -r -p "Обновить пакеты ОС перед установкой (может занять время)? [y/N]: " a
[[ "${a,,}" == "y" || "${a,,}" == "yes" ]] || { info "Обновление системы пропущено."; return 0; }
;;
esac
info "Обновляю пакеты системы..."
if [[ "$PKG" == "deb" ]]; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade \
&& ok "Система обновлена." || warn "apt upgrade завершился с предупреждениями."
else
local mgr; mgr="$(command -v dnf >/dev/null && echo dnf || echo yum)"
$mgr -y update && ok "Система обновлена." || warn "$mgr update завершился с предупреждениями."
fi
}
#--------------------- Репозиторий Angie ---------------------------------------
setup_angie_repo() {
info "Настраиваю репозиторий Angie ($OS_ID $OS_VERSION_ID)..."
if [[ "$PKG" == "deb" ]]; then
export DEBIAN_FRONTEND=noninteractive
apt-get install -y curl gnupg2 ca-certificates lsb-release >/dev/null 2>&1 \
|| apt-get install -y curl gnupg2 ca-certificates lsb-release
curl -fsSL https://angie.software/keys/angie-signing.gpg \
-o /usr/share/keyrings/angie-signing.gpg || die "Не удалось скачать ключ подписи Angie."
local rel="${OS_VERSION_ID}" cn="${OS_CODENAME}"
[[ -z "$rel" ]] && rel="$(lsb_release -rs 2>/dev/null || echo "")"
echo "deb [signed-by=/usr/share/keyrings/angie-signing.gpg] https://download.angie.software/angie/${OS_ID}/${rel}/ ${cn} main" \
> /etc/apt/sources.list.d/angie.list
apt-get update -y || die "apt-get update для репо Angie не удался (${OS_ID}/${rel}/${cn})."
ok "Репозиторий Angie подключён (${OS_ID}/${rel}/${cn})."
else
local rid="${OS_ID/centos/rhel}"
curl -fsSL -o /etc/yum.repos.d/angie.repo \
"https://download.angie.software/angie/${rid}/${EL_MAJOR}/x86_64/angie.repo" \
|| die "Не удалось скачать angie.repo для ${rid}/${EL_MAJOR}."
ok "Репозиторий Angie подключён (${rid}/${EL_MAJOR})."
fi
}
#------------------------------- Установка -------------------------------------
install_angie() {
if command -v angie >/dev/null 2>&1; then
ok "Angie уже установлен ($(angie -v 2>&1 | head -1))"; return 0
fi
info "Устанавливаю Angie..."
if [[ "$PKG" == "deb" ]]; then
apt-get install -y angie
else
(command -v dnf >/dev/null && dnf install -y angie) || yum install -y angie
fi
command -v angie >/dev/null 2>&1 || die "Установка Angie не удалась."
ok "Angie установлен: $(angie -v 2>&1 | head -1)"
}
install_console() {
[[ "$ENABLE_CONSOLE" == "yes" ]] || { info "Console Light пропущен."; return 0; }
if [[ -d "$CONSOLE_ROOT" ]] || [[ -d /usr/share/angie-console-light ]]; then
ok "Console Light уже установлен."; return 0
fi
info "Устанавливаю angie-console-light..."
if [[ "$PKG" == "deb" ]]; then
apt-get install -y angie-console-light || { warn "Пакет недоступен — мониторинг отключён."; ENABLE_CONSOLE="no"; return 0; }
else
(command -v dnf >/dev/null && dnf install -y angie-console-light) || yum install -y angie-console-light || { warn "Пакет недоступен — мониторинг отключён."; ENABLE_CONSOLE="no"; return 0; }
fi
ok "Console Light установлен."
}
install_modsecurity() {
[[ "$ENABLE_MODSEC" == "yes" ]] || { info "ModSecurity пропущен."; return 0; }
info "Устанавливаю ModSecurity + OWASP CRS..."
if [[ "$PKG" == "deb" ]]; then
apt-get install -y angie-module-modsecurity modsecurity-crs || { warn "Модуль недоступен — WAF отключён."; ENABLE_MODSEC="no"; return 0; }
else
(command -v dnf >/dev/null && dnf install -y angie-module-modsecurity) || yum install -y angie-module-modsecurity || { warn "Модуль недоступен — WAF отключён."; ENABLE_MODSEC="no"; return 0; }
fi
ok "ModSecurity установлен."
}
#------------------------------- TLS -------------------------------------------
generate_tls() {
mkdir -p "$TLS_DIR"; chmod 750 "$TLS_DIR"
local crt="${TLS_DIR}/${FQDN}.crt" key="${TLS_DIR}/${FQDN}.key" dh="${TLS_DIR}/dhparam.pem"
if [[ ! -s "$crt" || ! -s "$key" ]]; then
info "Генерирую self-signed сертификат-заглушку для $FQDN..."
openssl req -x509 -nodes -newkey rsa:2048 -days 397 -keyout "$key" -out "$crt" -subj "/CN=${FQDN}/O=ReverseProxy" -addext "subjectAltName=DNS:${FQDN}" >/dev/null 2>&1
chmod 600 "$key"; chmod 644 "$crt"
ok "Сертификат-заглушка создан."
else
ok "Сертификат уже существует."
fi
if [[ ! -s "$dh" ]]; then
info "Генерирую DH-параметры (2048 бит)..."
openssl dhparam -out "$dh" 2048 >/dev/null 2>&1
chmod 644 "$dh"
fi
}
#------------------------------- Конфигурация ----------------------------------
backup_configs() {
mkdir -p "$BACKUP_DIR"
local f
for f in "$SITE_CONF" "$SEC_CONF" "$CONSOLE_CONF" "${ANGIE_CONF_DIR}/angie.conf"; do
[[ -f "$f" ]] && cp -a "$f" "$BACKUP_DIR/"
done
ok "Бэкап конфигов: $BACKUP_DIR"
}
write_security_conf() {
mkdir -p "${ANGIE_CONF_DIR}/http.d"
cat > "$SEC_CONF" <<EOF
#=== 00-security.conf — глобальные http-level настройки =========================
server_tokens off;
client_max_body_size 512m;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
client_body_timeout 30s;
client_header_timeout 15s;
send_timeout 30s;
keepalive_timeout 65s;
reset_timedout_connection on;
limit_req_zone \$binary_remote_addr zone=req_web:10m rate=${RATE_RPS}r/s;
limit_req_zone \$binary_remote_addr zone=req_ws:10m rate=30r/s;
limit_conn_zone \$binary_remote_addr zone=conn_main:10m;
limit_req_status 429;
limit_conn_status 429;
set_real_ip_from 127.0.0.1;
# set_real_ip_from <IP_фронтового_балансировщика>;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
map \$http_upgrade \$connection_upgrade {
default upgrade;
"" close;
}
map \$request_method \$bad_method {
default 1;
GET 0; HEAD 0; POST 0; PUT 0; PATCH 0; DELETE 0; OPTIONS 0;
}
map \$http_user_agent \$bad_ua {
default 0;
"" 1;
"~*(nikto|sqlmap|nmap|masscan|acunetix|fimap|nessus)" 1;
}
EOF
ok "Записан $SEC_CONF"
}
write_site_conf() {
local modsec_on="" base loc_prefix ws_re
if [[ "$ENABLE_MODSEC" == "yes" ]]; then
modsec_on=$' modsecurity on;\n modsecurity_rules_file /etc/modsecurity.d/include.conf;'
fi
base="${ONEC_BASE:-}"
loc_prefix="${base:-/}"
ws_re="$(ws_paths_regex "$WS_PATHS")"
cat > "$SITE_CONF" <<EOF
#=== reverse-proxy.conf — публикация 1С (IIS) ==================================
upstream onec_iis {
zone onec_iis 64k;
server ${UPSTREAM_IP}:${UPSTREAM_PORT} max_fails=3 fail_timeout=15s;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name ${FQDN};
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${FQDN};
status_zone onec_site;
ssl_certificate ${TLS_DIR}/${FQDN}.crt;
ssl_certificate_key ${TLS_DIR}/${FQDN}.key;
ssl_dhparam ${TLS_DIR}/dhparam.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on; ssl_stapling_verify on;
resolver 1.1.1.1 9.9.9.9 valid=300s ipv6=off;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: https:; frame-ancestors 'self'" always;
limit_conn conn_main ${CONN_PER_IP};
if (\$bad_method) { return 405; }
if (\$bad_ua) { return 444; }
location ~ /\\.(?!well-known) { deny all; }
${modsec_on}
location ~* ^${base}/(${ws_re})/ {
limit_req zone=req_ws burst=60 nodelay;
proxy_pass ${UPSTREAM_SCHEME}://onec_iis;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Connection "";
proxy_connect_timeout 10s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
proxy_buffering off;
}
location ${loc_prefix} {
limit_req zone=req_web burst=${RATE_BURST} nodelay;
proxy_pass ${UPSTREAM_SCHEME}://onec_iis;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
proxy_buffering on;
proxy_buffers 16 32k;
proxy_buffer_size 32k;
proxy_busy_buffers_size 64k;
proxy_redirect ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}/ https://${FQDN}/;
proxy_redirect ${UPSTREAM_SCHEME}://${FQDN}/ https://${FQDN}/;
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
proxy_hide_header Server;
}
location = /healthz { access_log off; return 200 "ok\\n"; }
}
EOF
ok "Записан $SITE_CONF"
}
write_console_conf() {
if [[ "$ENABLE_CONSOLE" != "yes" ]]; then
rm -f "$CONSOLE_CONF" 2>/dev/null || true
info "Console-конфиг не создаётся."
return 0
fi
local allows="" n
for n in $TRUSTED_NETS; do allows+=" allow ${n};"$'\n'; done
cat > "$CONSOLE_CONF" <<EOF
#=== 10-console.conf — Angie Console Light (ВНУТРЕННИЙ доступ) ==================
server {
listen ${CONSOLE_PORT} ssl;
server_name ${FQDN};
status_zone console;
ssl_certificate ${TLS_DIR}/${FQDN}.crt;
ssl_certificate_key ${TLS_DIR}/${FQDN}.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
${allows} deny all;
auth_basic "Angie Console";
auth_basic_user_file ${ANGIE_CONF_DIR}/console.htpasswd;
alias ${CONSOLE_ROOT}/;
index index.html;
}
location /status/ {
api /status/;
${allows} deny all;
auth_basic "Angie Console";
auth_basic_user_file ${ANGIE_CONF_DIR}/console.htpasswd;
}
}
EOF
ok "Записан $CONSOLE_CONF"
local htp="${ANGIE_CONF_DIR}/console.htpasswd"
if [[ ! -s "$htp" ]]; then
local pw hash
pw="$(openssl rand -base64 12 2>/dev/null || echo "ChangeMe$RANDOM")"
hash="$(openssl passwd -apr1 "$pw" 2>/dev/null)"
if [[ -n "$hash" ]]; then
echo "admin:${hash}" > "$htp"; chmod 640 "$htp"
CONSOLE_USER="admin"; CONSOLE_PASS="$pw"
ok "Создан доступ к консоли (логин admin)."
else
warn "Не удалось сгенерировать htpasswd — создайте вручную."
fi
else
ok "console.htpasswd уже существует."
fi
}
ensure_include() {
local main="${ANGIE_CONF_DIR}/angie.conf"
[[ -f "$main" ]] || die "Не найден $main"
if ! grep -qE 'include\s+.*http\.d/\*\.conf' "$main"; then
warn "http.d/*.conf не подключён — добавляю в http{}"
if grep -q 'include .*conf.d/\*.conf' "$main"; then
sed -i 's#\(include .*conf.d/\*.conf;\)#\1\n include /etc/angie/http.d/*.conf;#' "$main"
else
warn "Авто-подключение не удалось. Добавьте вручную: include /etc/angie/http.d/*.conf; в http{}"
fi
fi
mkdir -p /var/www/acme
}
configure_firewall() {
local console_rule=""
[[ "$ENABLE_CONSOLE" == "yes" ]] && console_rule="$CONSOLE_PORT"
if command -v ufw >/dev/null 2>&1; then
ufw allow 80/tcp >/dev/null 2>&1 || true
ufw allow 443/tcp >/dev/null 2>&1 || true
if [[ -n "$console_rule" ]]; then
local n
for n in $TRUSTED_NETS; do ufw allow from "$n" to any port "$console_rule" proto tcp >/dev/null 2>&1 || true; done
fi
ok "UFW: 80,443$( [[ -n "$console_rule" ]] && echo ", $console_rule (только доверенным)" )"
elif command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --permanent --add-service=http >/dev/null 2>&1 || true
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 || true
if [[ -n "$console_rule" ]]; then
local n
for n in $TRUSTED_NETS; do
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=$n port port=$console_rule protocol=tcp accept" >/dev/null 2>&1 || true
done
fi
firewall-cmd --reload >/dev/null 2>&1 || true
ok "firewalld настроен."
else
warn "Не найден ufw/firewalld — откройте порты вручную."
fi
}
validate_and_start() {
info "Проверяю конфигурацию (angie -t)..."
if ! angie -t 2>>"$LOG_FILE"; then
err "Конфигурация невалидна. Восстанавливаю из бэкапа $BACKUP_DIR"
local f
for f in reverse-proxy.conf 00-security.conf 10-console.conf; do
[[ -f "$BACKUP_DIR/$f" ]] && cp -a "$BACKUP_DIR/$f" "${ANGIE_CONF_DIR}/http.d/$f"
done
die "Откат выполнен. Смотрите $LOG_FILE"
fi
ok "Конфигурация валидна."
systemctl enable angie >/dev/null 2>&1 || true
if systemctl is-active --quiet angie; then
systemctl reload angie && ok "Angie перезагружен (reload)."
else
systemctl restart angie && ok "Angie запущен (restart)."
fi
}
smoke_test() {
info "Smoke-тест..."
sleep 1
ss -tlnp 2>/dev/null | grep -q ':443' && ok "Порт 443 слушается." || warn "443 не найден — systemctl status angie"
if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
ss -tlnp 2>/dev/null | grep -q ":${CONSOLE_PORT}" && ok "Порт консоли ${CONSOLE_PORT} слушается." || warn "Порт ${CONSOLE_PORT} не найден."
fi
if command -v curl >/dev/null 2>&1; then
local code
code="$(curl -kso /dev/null -w '%{http_code}' "https://127.0.0.1/healthz" -H "Host: ${FQDN}" || echo 000)"
[[ "$code" == "200" ]] && ok "healthz -> HTTP $code" || warn "healthz -> HTTP $code"
fi
}
print_summary() {
local base="${ONEC_BASE:-}" ws_list="" p
for p in $WS_PATHS; do ws_list+="${ws_list:+ , }${base}/${p}/"; done
cat <<EOF | tee -a "$LOG_FILE"
================= РАЗВЁРТЫВАНИЕ ЗАВЕРШЕНО =================
FQDN: ${FQDN}
Вход (1С): https://${FQDN}${ONEC_BASE:-/} (TCP/443, TLS1.2/1.3)
Бэкенд IIS: ${UPSTREAM_SCHEME}://${UPSTREAM_IP}:${UPSTREAM_PORT}${ONEC_BASE:-/}
Веб-сервисы 1С: ${ws_list} (отдельная зона лимитов)
Rate limit: web ${RATE_RPS} r/s (burst ${RATE_BURST}); ws 30 r/s; conn/IP ${CONN_PER_IP}
ModSecurity/CRS: ${ENABLE_MODSEC}
EOF
if [[ "$ENABLE_CONSOLE" == "yes" ]]; then
cat <<EOF | tee -a "$LOG_FILE"
Console Light: https://<внутр.IP>:${CONSOLE_PORT}/ (только сети: ${TRUSTED_NETS})
Логин/пароль: ${CONSOLE_USER:-admin} / ${CONSOLE_PASS:-<см. console.htpasswd>}
ВНИМАНИЕ: консоль НЕ опубликована на 443 и не должна быть доступна извне.
EOF
fi
cat <<EOF | tee -a "$LOG_FILE"
Конфиги: ${SITE_CONF}
${SEC_CONF}
TLS: ${TLS_DIR}/${FQDN}.{crt,key} (ЗАГЛУШКА — замените!)
Бэкап: ${BACKUP_DIR}
Лог: ${LOG_FILE}
СЛЕДУЮЩИЕ ШАГИ:
1) Замените self-signed сертификат на боевой (ACME/корп. CA).
2) Укажите set_real_ip_from для фронтового LB/FortiGate в 00-security.conf.
3) Проверьте вход: тонкий клиент 1С + один веб-сервис (/ws/).
4) Если UI 1С "поехал" — ослабьте CSP под конфигурацию.
5) Настройте fail2ban на 429/444.
=========================================================
EOF
}
main() {
require_root
: > "$LOG_FILE" 2>/dev/null || true
info "Старт $SCRIPT_NAME"
detect_os
prompt_inputs
ensure_base_repos
update_system
setup_angie_repo
install_angie
install_console
install_modsecurity
generate_tls
backup_configs
write_security_conf
write_site_conf
write_console_conf
ensure_include
configure_firewall
validate_and_start
smoke_test
print_summary
}
main "$@"
bash
Проверка состояния кластера Proxmox
Быстрый health-check узлов и кворума.
Показать код
#!/usr/bin/env bash
set -euo pipefail
pvecm status
pvesh get /cluster/resources --type node
powershell1
powershell
Сканирование инфраструктуры AD на наличие Mimikatz
Показать код
<#
.SYNOPSIS
Полное сканирование инфраструктуры AD на следы credential theft tools.
.DESCRIPTION
Blue Team скрипт. Сигнатурные строки закодированы в Base64 для
предотвращения ложного срабатывания AV. Режимы: Detect | Remediate | Report
.NOTES
Copyright Sergey Issayev
PowerShell 5.1+, RSAT AD, WinRM | DA/EA rights | v1.1
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string[]]$Domains,
[ValidateSet('Detect','Remediate','Report')][string]$Mode = 'Detect',
[string]$TargetOU,
[string[]]$ExcludeComputers = @(),
[ValidateRange(1,50)][int]$MaxConcurrent = 10,
[ValidateRange(1,8760)][int]$HoursBack = 168,
[System.Management.Automation.PSCredential]$Credential,
[string]$OutputPath = "$env:USERPROFILE\Desktop\InfraAudit_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
)
#region ═══ AV-SAFE: SIGNATURE DECODE ═══
function _d([string]$b){[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b))}
$S = @{
T1=_d 'bWltaWthdHo='; TLib=_d 'bWltaWxpYg=='; TDrv=_d 'bWltaWRydg=='
TLsa=_d 'bWltaWxzYQ=='; Sek=_d 'c2VrdXJsc2E='; Rub=_d 'cnViZXVz'
Laz=_d 'bGF6YWduZQ=='; PrD=_d 'cHJvY2R1bXA='; NaD=_d 'bmFub2R1bXA='
SfK=_d 'c2FmZXR5a2F0eg=='; ShD=_d 'c2hhcnBkdW1w'; HaK=_d 'aGFuZGxla2F0eg=='
PhM=_d 'cGh5c21lbTJwcm9maXQ='; Dmp=_d 'ZHVtcGVydA=='; PLB=_d 'UFBMQmxhZGU='
PLD=_d 'UFBMZHVtcA=='; Lsy=_d 'bHNhc3N5'; PyK=_d 'cHlweWthdHo='
Kwi=_d 'a2l3aQ=='; CG=_d 'a2VyYmVyb3M6OmdvbGRlbg=='
CPT=_d 'a2VyYmVyb3M6OnB0dA=='; CDb=_d 'cHJpdmlsZWdlOjpkZWJ1Zw=='
CEl=_d 'dG9rZW46OmVsZXZhdGU='; CCa=_d 'Y3J5cHRvOjpjYXBp'
CDp=_d 'ZHBhcGk6Og=='; CVa=_d 'dmF1bHQ6Og=='; CLd=_d 'bHNhZHVtcA=='
CDs=_d 'bHNhZHVtcDo6ZGNzeW5j'; CSk=_d 'bWlzYzo6c2tlbGV0b24='
IT1=_d 'aW52b2tlLW1pbWlrYXR6'; IKb=_d 'aW52b2tlLWtlcmJlcm9hc3Q='
IRb=_d 'aW52b2tlLXJ1YmV1cw=='; GNt=_d 'Z2V0LW50bG1jaGFsbGVuZ2U='
Skl=_d 'c2tlbGV0b24='; DCS=_d 'ZGNzeW5j'; KbR=_d 'a2VyYmVyb2FzdA=='
}
$_e={param([string[]]$a)($a|%{[regex]::Escape($_)})-join'|'}
$script:SR=@{
Proc='(?i)'+(&$_e @($S.T1,'mimi',$S.Sek,$S.Kwi,$S.Laz,$S.PrD,$S.NaD,$S.PyK,$S.SfK,$S.ShD,$S.HaK,$S.PhM,$S.Dmp,$S.PLB,$S.PLD,$S.Lsy))
Files=@("$($S.T1)*","$($S.TLib)*","$($S.TDrv)*","$($S.TLsa)*","$($S.Sek)*","$($S.Kwi)*","$($S.Laz)*","$($S.PrD)*","$($S.NaD)*","$($S.SfK)*","$($S.ShD)*","$($S.Rub)*","$($S.IT1)*","$($S.PyK)*","$($S.Lsy)*","$($S.HaK)*","$($S.PLB)*","$($S.PLD)*","$($S.Dmp)*")
Cmd='(?i)'+(&$_e @($S.T1,$S.Sek,$S.CLd,$S.CG,$S.CPT,$S.CDb,$S.CEl,$S.CCa,$S.CDp,$S.CVa,$S.IT1,$S.Rub,$S.ShD))
PS='(?i)'+(&$_e @($S.IT1,$S.Sek,$S.CG,$S.CDs,$S.CEl,$S.CDb,$S.IKb,$S.GNt,$S.IRb))
Task='(?i)'+(&$_e @($S.T1,$S.Sek,$S.Kwi,$S.Laz,$S.Rub,$S.IT1,$S.ShD))
Svc='(?i)'+(&$_e @($S.T1,$S.TLib,$S.TDrv,$S.Kwi,$S.Sek,$S.Skl))
GPO='(?i)'+(&$_e @($S.T1,$S.Sek,$S.IT1,$S.Rub,$S.KbR,$S.DCS,$S.ShD))
Sm1='(?i)'+(&$_e @($S.T1,$S.Sek,$S.CLd,$S.CG,$S.CDb,$S.IT1,$S.Rub,$S.SfK,$S.ShD))
LsaOK=@('','kerberos','msv1_0','schannel','wdigest','tspkg','pku2u','cloudap','negoextender','wsauth')
AuthOK=@('msv1_0','')
NotifOK=@('scecli','rassfm','')
}
#endregion
#region ═══ GLOBALS ═══
$ErrorActionPreference='Continue'
$script:T0=[DateTime]::Now
$script:AF=[System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
$script:RL=[System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
$script:GS=@{Total=0;Scanned=0;Unreach=0;Findings=0;Crit=0;High=0;Med=0;Low=0;Fixes=0}
function Write-B{param([string]$T)Write-Host "`n$('='*80)" -ForegroundColor Cyan;Write-Host " $T" -ForegroundColor Cyan;Write-Host "$('='*80)" -ForegroundColor Cyan}
function Write-S{param([string]$T)Write-Host " [*] $T" -ForegroundColor White}
function Write-G{param([string]$T)Write-Host " [OK] $T" -ForegroundColor Green}
function Write-A{param([string]$V,[string]$T)$c=switch($V){'CRITICAL'{'Red'}'HIGH'{'Magenta'}'MEDIUM'{'DarkYellow'}default{'Gray'}};Write-Host " [$V] $T" -ForegroundColor $c}
function Write-F{param([string]$T)Write-Host " [FIX] $T" -ForegroundColor Blue}
#endregion
#region ═══ REMOTE SCAN SCRIPTBLOCK ═══
$ScanSB = {
param([string]$HN,[string]$HR,[int]$HB,[string]$Md,[hashtable]$SR,[hashtable]$Sig)
$fin=[System.Collections.ArrayList]::new()
$rem=[System.Collections.ArrayList]::new()
$hi=@{Name=$env:COMPUTERNAME;Role=$HR;ScanTime=Get-Date;Status='OK'
OS=(Get-CimInstance Win32_OperatingSystem).Caption
Boot=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime}
function AF{param([string]$C,[string]$V,[string]$D,[string]$Dt='',[string]$R='',[string]$M='')
$null=$fin.Add([PSCustomObject]@{Host=$env:COMPUTERNAME;Role=$HR;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss'
Category=$C;Severity=$V;Description=$D;Detail=$Dt;Remediation=$R;MitreID=$M})}
# 1. Processes
$procs=Get-Process -EA SilentlyContinue|Select Id,Name,Path,StartTime
foreach($p in $procs){
if($p.Name -match $SR.Proc){
AF 'SuspProcess' 'CRITICAL' "Подозрительный процесс: $($p.Name) (PID $($p.Id))" "Путь: $($p.Path)" 'Завершить. Изолировать хост.' 'T1003'
if($Md -eq 'Remediate'){try{Stop-Process -Id $p.Id -Force -EA Stop;$null=$rem.Add("Процесс $($p.Name) PID$($p.Id) завершён")}catch{$null=$rem.Add("ERR: $($p.Name): $_")}}
}
if($p.Path -and $p.Path -match '(?i)\\Temp\\|\\Downloads\\|\\AppData\\Local\\Temp|\\Public\\|\\ProgramData\\debug|\\Recycle'){
AF 'SuspProcess' 'HIGH' "Процесс из подозрительного пути: $($p.Name)" "Путь: $($p.Path)" 'Проверить.' 'T1059'
}
}
# 2. LSA/WDigest/CredGuard/CredSSP
$ppl=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name RunAsPPL -EA SilentlyContinue
if(-not $ppl -or $ppl.RunAsPPL -ne 1){
AF 'Config' 'HIGH' 'RunAsPPL не включён' '' 'Включить RunAsPPL=1' 'T1003.001'
if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name RunAsPPL -Value 1 -Type DWord -Force;$null=$rem.Add("RunAsPPL=1")}
}
$wd=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest' -Name UseLogonCredential -EA SilentlyContinue
if($wd -and $wd.UseLogonCredential -eq 1){
AF 'Config' 'CRITICAL' 'WDigest UseLogonCredential=1' '' 'Установить 0' 'T1003.001'
if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest' -Name UseLogonCredential -Value 0 -Type DWord -Force;$null=$rem.Add("WDigest=0")}
}
try{$dg=Get-CimInstance Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard -EA SilentlyContinue
if(-not $dg -or $dg.SecurityServicesRunning -notcontains 1){AF 'Config' 'MEDIUM' 'Credential Guard не активен' '' 'GPO' 'T1003.001'}}catch{}
$cs1=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation' -Name AllowDefaultCredentials -EA SilentlyContinue
$cs2=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation' -Name AllowDefCredentialsWhenNTLMOnly -EA SilentlyContinue
if($cs1 -or $cs2){AF 'Config' 'MEDIUM' 'CredSSP делегирование разрешено' '' 'Remote Credential Guard' 'T1550'}
# 3. LSA Packages
$lp=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Security Packages' -EA SilentlyContinue).'Security Packages'
$uk=$lp|?{$_ -and $_ -notin $SR.LsaOK}
if($uk){AF 'SkelKey' 'CRITICAL' "Нестандартные LSA Packages: $($uk -join ', ')" '' 'Удалить. Перезагрузить.' 'T1556.001'
if($Md -eq 'Remediate'){$cl=$lp|?{$_ -in $SR.LsaOK -or $_ -eq ''};Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Security Packages' -Value $cl -Force;$null=$rem.Add("LSA packages cleaned")}}
$ap=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Authentication Packages' -EA SilentlyContinue).'Authentication Packages'
$ua=$ap|?{$_ -and $_ -notin $SR.AuthOK}
if($ua){AF 'SkelKey' 'HIGH' "Нестандартные Auth Packages: $($ua -join ', ')" '' 'Проверить.' 'T1556.001'}
$np=(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'Notification Packages' -EA SilentlyContinue).'Notification Packages'
$un=$np|?{$_ -and $_ -notin $SR.NotifOK}
if($un){AF 'Persistence' 'CRITICAL' "Password Notification Packages: $($un -join ', ')" 'Password filter DLL' 'Удалить.' 'T1556.002'}
# 4. Files
$sp=@("$env:SystemRoot\Temp","$env:SystemRoot\System32","$env:TEMP","$env:PUBLIC","$env:USERPROFILE\Downloads","$env:USERPROFILE\Desktop","$env:ProgramData")
foreach($d in ($sp|Select -Unique)){
if(-not(Test-Path $d)){continue}
foreach($pat in $SR.Files){
$ff=Get-ChildItem $d -Filter $pat -Recurse -Depth 2 -EA SilentlyContinue
foreach($f in $ff){
AF 'MalFile' 'CRITICAL' "Вредоносный файл: $($f.FullName)" "$([math]::Round($f.Length/1KB,1))KB | $($f.CreationTime)" 'Карантин.' 'T1003'
if($Md -eq 'Remediate'){$q="$env:SystemRoot\Quarantine";if(-not(Test-Path $q)){New-Item $q -ItemType Directory -Force|Out-Null};Move-Item $f.FullName (Join-Path $q "$(Get-Date -F 'yyyyMMddHHmmss')_$($f.Name)") -Force -EA SilentlyContinue;$null=$rem.Add("Quarantine: $($f.FullName)")}
}
}
$dps=Get-ChildItem $d -Include '*.dmp','*.kirbi','*.ccache' -Recurse -Depth 2 -EA SilentlyContinue|?{$_.Length -gt 1KB}
foreach($dp in $dps){
$sv=if($dp.Extension -in '.kirbi','.ccache'){'CRITICAL'}elseif($dp.Length -gt 30MB){'CRITICAL'}else{'HIGH'}
AF 'CredDump' $sv "Дамп/билет: $($dp.FullName)" "$([math]::Round($dp.Length/1MB,2))MB" 'Изъять.' 'T1003.001'
}
}
# 5. Services/Drivers
$svcs=Get-CimInstance Win32_Service -EA SilentlyContinue
foreach($sv in $svcs){
$str="$($sv.Name) $($sv.DisplayName) $($sv.PathName)"
if($str -match $SR.Svc){
AF 'MalService' 'CRITICAL' "Служба: $($sv.Name)" "Путь: $($sv.PathName)" 'Удалить.' 'T1543.003'
if($Md -eq 'Remediate' -and $sv.State -eq 'Running'){Stop-Service $sv.Name -Force -EA SilentlyContinue;Set-Service $sv.Name -StartupType Disabled -EA SilentlyContinue;$null=$rem.Add("Svc $($sv.Name) stopped")}
}
if($sv.StartName -match 'LocalSystem' -and $sv.PathName -and $sv.PathName -match '(?i)\\Temp\\|\\Downloads\\|\\AppData\\|\\Public\\|\\Recycle'){
AF 'SuspService' 'HIGH' "SYSTEM из Temp: $($sv.Name)" "$($sv.PathName)" 'Проверить.' 'T1543.003'
}
}
$drvs=Get-CimInstance Win32_SystemDriver -EA SilentlyContinue|?{$_.PathName -match $SR.Svc}
foreach($dr in $drvs){AF 'MalDriver' 'CRITICAL' "Драйвер: $($dr.Name)" "$($dr.PathName)" 'Удалить.' 'T1014'}
# 6. Scheduled Tasks
try{$tasks=Get-ScheduledTask -EA SilentlyContinue|?{$_.State -ne 'Disabled'}
foreach($t in $tasks){$act=($t.Actions|%{"$($_.Execute) $($_.Arguments)"})-join' '
if($act -match $SR.Task){AF 'Persist' 'CRITICAL' "Task: $($t.TaskName)" $act.Substring(0,[Math]::Min(300,$act.Length)) 'Удалить.' 'T1053.005'
if($Md -eq 'Remediate'){Unregister-ScheduledTask -TaskName $t.TaskName -Confirm:$false -EA SilentlyContinue;$null=$rem.Add("Task: $($t.TaskName)")}}
elseif($act -match '(?i)encodedcommand|frombase64|downloadstring|hidden|bypass|iex\('){AF 'Persist' 'HIGH' "Подозр. task: $($t.TaskName)" $act.Substring(0,[Math]::Min(300,$act.Length)) 'Проверить.' 'T1053.005'}
}}catch{}
# 7. WMI
try{$wc=Get-CimInstance -Namespace root\subscription -ClassName CommandLineEventConsumer -EA SilentlyContinue
foreach($w in $wc){$cm="$($w.CommandLineTemplate) $($w.ExecutablePath)"
if($cm -match $SR.Task -or $cm -match '(?i)powershell.*enc|downloadstring|bypass'){AF 'Persist' 'CRITICAL' "WMI: $($w.Name)" $w.CommandLineTemplate 'Удалить.' 'T1546.003'
if($Md -eq 'Remediate'){$w|Remove-CimInstance -EA SilentlyContinue;$null=$rem.Add("WMI: $($w.Name)")}}}
$sc=Get-CimInstance -Namespace root\subscription -ClassName ActiveScriptEventConsumer -EA SilentlyContinue
foreach($s in $sc){AF 'Persist' 'HIGH' "WMI Script: $($s.Name)" $s.ScriptingEngine 'Проверить.' 'T1546.003'}}catch{}
# 8. Autorun
$arks=@('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run','HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce','HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run','HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce','HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon')
foreach($k in $arks){if(-not(Test-Path $k)){continue};$vs=Get-ItemProperty $k -EA SilentlyContinue
foreach($pr in ($vs.PSObject.Properties|?{$_.Name -notmatch '^PS'})){$vl="$($pr.Value)"
if($vl -match $SR.Task){AF 'Persist' 'CRITICAL' "Autorun: $k\$($pr.Name)" $vl 'Удалить.' 'T1547.001'
if($Md -eq 'Remediate'){Remove-ItemProperty $k -Name $pr.Name -Force -EA SilentlyContinue;$null=$rem.Add("Autorun: $($pr.Name)")}}
elseif($vl -match '(?i)encodedcommand|frombase64|downloadstring|hidden.*bypass'){AF 'Persist' 'HIGH' "Подозр. autorun: $k\$($pr.Name)" $vl.Substring(0,[Math]::Min(200,$vl.Length)) 'Проверить.' 'T1547.001'}
}}
# 9. Event Logs
$since=(Get-Date).AddHours(-$HB)
try{$e4688=Get-WinEvent -FilterHashtable @{LogName='Security';Id=4688;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[5].Value -match $SR.Cmd}
foreach($e in $e4688){AF 'EvtLog' 'CRITICAL' "Cmd(4688): $($e.Properties[5].Value)" "User: $($e.Properties[1].Value)" 'Расследовать.' 'T1059.001'}}catch{}
try{$e4672=Get-WinEvent -FilterHashtable @{LogName='Security';Id=4672;StartTime=$since} -EA SilentlyContinue
if($e4672){$gr=$e4672|Group-Object{$_.Properties[1].Value}|?{$_.Count -ge 50 -and $_.Name -notmatch '\$|SYSTEM|LOCAL SERVICE|NETWORK SERVICE'}|Sort Count -Desc|Select -First 5
foreach($g in $gr){AF 'EvtLog' 'MEDIUM' "Привилегии: $($g.Name) ($($g.Count)x)" '' 'Проверить.' 'T1134'}}}catch{}
try{$sm10=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=10;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[8].Value -match '(?i)lsass\.exe' -and $_.Properties[7].Value -match '0x1010|0x1410|0x1438|0x143a|0x1fffff'}
foreach($s in $sm10){AF 'LSASS' 'CRITICAL' "LSASS access(Sysmon10): $($s.Properties[5].Value)" "Access: $($s.Properties[7].Value)" 'Изолировать.' 'T1003.001'}}catch{}
try{$sm8=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=8;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[7].Value -match '(?i)lsass\.exe'}
foreach($s in $sm8){AF 'LSASS' 'CRITICAL' "RemoteThread LSASS(Sysmon8): $($s.Properties[4].Value)" "PID: $($s.Properties[3].Value)" 'Изолировать.' 'T1055.003'}}catch{}
try{$sm1=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational';Id=1;StartTime=$since} -EA SilentlyContinue|?{$_.Message -match $SR.Sm1}
foreach($s in $sm1){AF 'EvtLog' 'CRITICAL' "Tool launch(Sysmon1)" $s.Properties[10].Value.Substring(0,[Math]::Min(400,$s.Properties[10].Value.Length)) 'Изолировать.' 'T1003'}}catch{}
try{$ps4104=Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-PowerShell/Operational';Id=4104;StartTime=$since} -EA SilentlyContinue|?{$_.Message -match $SR.PS}
foreach($p in $ps4104){AF 'EvtLog' 'CRITICAL' "PS ScriptBlock(4104)" $p.Message.Substring(0,[Math]::Min(400,$p.Message.Length)) 'Расследовать.' 'T1059.001'}}catch{}
try{$e7045=Get-WinEvent -FilterHashtable @{LogName='System';Id=7045;StartTime=$since} -EA SilentlyContinue|?{$_.Properties[1].Value -match $SR.Svc -or $_.Properties[1].Value -match '(?i)cmd\.exe|powershell|comspec|\\Temp\\|\\Downloads\\' -or $_.Properties[0].Value -match $SR.Svc}
foreach($e in $e7045){AF 'EvtLog' 'CRITICAL' "Svc install(7045): $($e.Properties[0].Value)" "$($e.Properties[1].Value)" 'Удалить.' 'T1543.003'}}catch{}
# 10. Network
$bad=@(4444,5555,8888,1234,31337,6666,9999,13337)
try{$li=Get-NetTCPConnection -State Listen -EA SilentlyContinue|?{$_.LocalPort -in $bad}
foreach($l in $li){$pr=Get-Process -Id $l.OwningProcess -EA SilentlyContinue;AF 'Net' 'HIGH' "Listen :$($l.LocalPort)" "Proc: $($pr.Name)" 'Проверить C2.' 'T1571'}}catch{}
try{$ob=Get-NetTCPConnection -State Established -EA SilentlyContinue|?{$_.RemoteAddress -notmatch '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|::1|fe80)' -and $_.RemotePort -in $bad}
foreach($o in $ob){$pr=Get-Process -Id $o.OwningProcess -EA SilentlyContinue;AF 'Net' 'HIGH' "Outbound $($o.RemoteAddress):$($o.RemotePort)" "$($pr.Name)" 'Block.' 'T1571'}}catch{}
# 11. DC-specific
if($HR -eq 'DC'){
try{$ds=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name DsrmAdminLogonBehavior -EA SilentlyContinue
if($ds -and $ds.DsrmAdminLogonBehavior -eq 2){AF 'DCBack' 'CRITICAL' 'DSRM=2 backdoor!' '' 'Set 0.' 'T1003.003'
if($Md -eq 'Remediate'){Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name DsrmAdminLogonBehavior -Value 0 -Type DWord -Force;$null=$rem.Add("DSRM=0")}}}catch{}
$spl=Get-Service Spooler -EA SilentlyContinue
if($spl -and $spl.Status -eq 'Running'){AF 'DCCfg' 'HIGH' 'Spooler on DC' 'PrintNightmare' 'Stop.' 'T1210'
if($Md -eq 'Remediate'){Stop-Service Spooler -Force -EA SilentlyContinue;Set-Service Spooler -StartupType Disabled -EA SilentlyContinue;$null=$rem.Add("Spooler off")}}
try{$smb=Get-SmbServerConfiguration -EA SilentlyContinue;if($smb.EnableSMB1Protocol){AF 'DCCfg' 'HIGH' 'SMBv1 on DC' '' 'Disable.' 'T1210'
if($Md -eq 'Remediate'){Set-SmbServerConfiguration -EnableSMB1Protocol $false -Confirm:$false -EA SilentlyContinue;$null=$rem.Add("SMBv1 off")}}}catch{}
try{$gd="$env:SystemRoot\SYSVOL\domain\Policies";if(Test-Path $gd){$gs=Get-ChildItem $gd -Recurse -Include '*.ps1','*.bat','*.cmd','*.vbs' -EA SilentlyContinue
foreach($g in $gs){$ct=Get-Content $g.FullName -Raw -EA SilentlyContinue;if($ct -match $SR.GPO){AF 'GPOBack' 'CRITICAL' "GPO script: $($g.FullName)" "$($g.LastWriteTime)" 'Remove.' 'T1484.001'}}}}catch{}
}
# 12. Audit config
try{$au=&auditpol /get /subcategory:"Process Creation" 2>$null;if($au -notmatch 'Success'){AF 'Audit' 'MEDIUM' 'Process Creation audit off' '' 'Enable.' 'T1562.002'}
$ca=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -EA SilentlyContinue
if(-not $ca -or $ca.ProcessCreationIncludeCmdLine_Enabled -ne 1){AF 'Audit' 'MEDIUM' 'CmdLine in 4688 off' '' 'GPO.' 'T1562.002'}}catch{}
try{$pl=Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Name EnableScriptBlockLogging -EA SilentlyContinue
if(-not $pl -or $pl.EnableScriptBlockLogging -ne 1){AF 'Audit' 'MEDIUM' 'PS ScriptBlock Logging off' '' 'GPO.' 'T1562.002'}}catch{}
return [PSCustomObject]@{HostInfo=$hi;Findings=$fin;Remediations=$rem}
}
#endregion
#region ═══ ORCHESTRATION ═══
function Get-Targets{
param([string]$Domain)
Write-S "Перечисление: $Domain"
$p=@{Server=$Domain;ErrorAction='Stop'}
$dcs=Get-ADDomainController -Filter * @p|%{[PSCustomObject]@{Name=$_.HostName;Role='DC';OS=$_.OperatingSystem}}
Write-G " DC: $($dcs.Count)"
$fp=if($TargetOU){@{Filter='Enabled -eq $true';SearchBase=$TargetOU;Properties=@('OperatingSystem','LastLogonDate')}}else{@{Filter='Enabled -eq $true';Properties=@('OperatingSystem','LastLogonDate')}}
$comps=Get-ADComputer @fp @p|?{$_.Name -notin $ExcludeComputers -and $_.Name -notin ($dcs.Name -replace '\..*$','') -and $_.LastLogonDate -gt (Get-Date).AddDays(-30) -and $_.DNSHostName}|%{
$r=if($_.OperatingSystem -match 'Server'){'Server'}else{'Workstation'}
[PSCustomObject]@{Name=$_.DNSHostName;Role=$r;OS=$_.OperatingSystem}}
Write-G " Servers: $(($comps|?{$_.Role -eq 'Server'}).Count) | WS: $(($comps|?{$_.Role -eq 'Workstation'}).Count)"
return @($dcs)+@($comps)
}
function Start-Scan{
Write-B "СКАНИРОВАНИЕ ИНФРАСТРУКТУРЫ"
if(-not $Domains){try{$forest=Get-ADForest;$Domains=$forest.Domains;Write-G "Лес: $($forest.Name)"}catch{$Domains=@((Get-ADDomain).DNSRoot)}}
$targets=foreach($d in $Domains){try{Get-Targets $d}catch{Write-Warning "Err $d : $_"}}
$script:GS.Total=$targets.Count
Write-S "Целей: $($targets.Count) | Потоков: $MaxConcurrent"
$ss=[System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$pool=[System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1,$MaxConcurrent,$ss,$Host)
$pool.Open()
$jobs=[System.Collections.ArrayList]::new()
$n=0
foreach($tgt in $targets){
$n++
if(-not $tgt.Name){$script:GS.Unreach++;continue}
Write-Progress -Activity Scan -Status "$($tgt.Name) [$n/$($targets.Count)]" -PercentComplete (($n/$targets.Count)*100)
if(-not(Test-Connection $tgt.Name -Count 1 -Quiet -ErrorAction SilentlyContinue)){
Write-Warning " [SKIP] $($tgt.Name) ICMP";$script:GS.Unreach++
$null=$script:AF.Add([PSCustomObject]@{Host=$tgt.Name;Role=$tgt.Role;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Connect';Severity='INFO';Description='Unreachable';Detail='ICMP';Remediation='Check net';MitreID=''})
continue}
$ok=$false
try{$se=if($Credential){New-PSSession $tgt.Name -Credential $Credential -EA Stop}else{New-PSSession $tgt.Name -EA Stop};Remove-PSSession $se;$ok=$true}
catch{Write-Warning " [SKIP] $($tgt.Name) WinRM";$script:GS.Unreach++
$null=$script:AF.Add([PSCustomObject]@{Host=$tgt.Name;Role=$tgt.Role;Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Connect';Severity='LOW';Description='WinRM fail';Detail=$_.Exception.Message;Remediation='Enable-PSRemoting';MitreID=''})
continue}
if($ok){
$ps=[PowerShell]::Create();$ps.RunspacePool=$pool
$null=$ps.AddScript($ScanSB)
$null=$ps.AddParameter('HN',$tgt.Name).AddParameter('HR',$tgt.Role).AddParameter('HB',$HoursBack).AddParameter('Md',$Mode).AddParameter('SR',$script:SR).AddParameter('Sig',$S)
$h=$ps.BeginInvoke()
$null=$jobs.Add([PSCustomObject]@{PS=$ps;H=$h;T=$tgt})
}
}
Write-B "СБОР РЕЗУЛЬТАТОВ"
$done=0
foreach($j in $jobs){
try{if($j.H.AsyncWaitHandle.WaitOne([TimeSpan]::FromMinutes(5))){
$r=$j.PS.EndInvoke($j.H)
if($r){$script:GS.Scanned++
foreach($f in $r.Findings){$null=$script:AF.Add($f);$script:GS.Findings++
switch($f.Severity){'CRITICAL'{$script:GS.Crit++}'HIGH'{$script:GS.High++}'MEDIUM'{$script:GS.Med++}'LOW'{$script:GS.Low++}}
Write-A $f.Severity "$($f.Host)|$($f.Description)"}
foreach($rm in $r.Remediations){$null=$script:RL.Add("$($j.T.Name)|$rm");$script:GS.Fixes++;Write-F "$($j.T.Name)|$rm"}
if(@($r.Findings).Count -eq 0){Write-G "$($j.T.Name) clean"}
}}else{Write-Warning "[TIMEOUT] $($j.T.Name)";$script:GS.Unreach++}
}catch{Write-Warning "[ERR] $($j.T.Name): $_"}finally{$j.PS.Dispose()}
$done++;Write-Progress -Activity Results -PercentComplete (($done/$jobs.Count)*100)
}
$pool.Close();$pool.Dispose();Write-Progress -Activity Results -Completed
}
#endregion
#region ═══ AD-WIDE CHECKS ═══
function Test-ADWide{
Write-B "ПРОВЕРКИ ДОМЕНА / ЛЕСА"
foreach($dom in $Domains){
Write-S "Домен: $dom"
$dc=(Get-ADDomainController -Discover -DomainName $dom -EA SilentlyContinue).HostName[0]
if(-not $dc){Write-Warning "DC n/a: $dom";continue}
try{$kb=Get-ADUser krbtgt -Properties PasswordLastSet -Server $dc
$age=(New-TimeSpan -Start $kb.PasswordLastSet -End(Get-Date)).Days
if($age -gt 180){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='GoldenTicket';Severity='HIGH';Description="[$dom] krbtgt: $age дней";Detail="Last: $($kb.PasswordLastSet)";Remediation='Double reset';MitreID='T1558.001'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] krbtgt $age d"}
else{Write-G "[$dom] krbtgt ${age}d"}}catch{Write-Warning "krbtgt: $_"}
try{$dn=(Get-ADDomain -Server $dc).DistinguishedName;$acl=Get-Acl "AD:\$dn"
$rg=@('1131f6aa-9c07-11d1-f79f-00c04fc2dcd2','1131f6ad-9c07-11d1-f79f-00c04fc2dcd2')
$lg=@('Domain Controllers','Enterprise Domain Controllers','Administrators','Enterprise Admins','Domain Admins')
$sus=$acl.Access|?{$rg -contains $_.ObjectType.ToString() -and $_.AccessControlType -eq 'Allow' -and $_.IdentityReference.Value.Split('\')[-1] -notin $lg}
foreach($s in $sus){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='ReplAbuse';Severity='CRITICAL';Description="[$dom] Repl ACL: $($s.IdentityReference)";Detail="$($s.ObjectType)";Remediation='Remove ACE';MitreID='T1003.006'});$script:GS.Crit++;$script:GS.Findings++;Write-A 'CRITICAL' "[$dom] Repl ACL abuse: $($s.IdentityReference)"
if($Mode -eq 'Remediate'){$acl.RemoveAccessRule($s)|Out-Null;Set-Acl "AD:\$dn" $acl;$null=$script:RL.Add("AD|[$dom] Repl ACE removed: $($s.IdentityReference)");$script:GS.Fixes++}}
if(-not $sus){Write-G "[$dom] Repl ACL OK"}}catch{Write-Warning "Repl ACL: $_"}
try{$sh=Get-ADUser -Filter{SIDHistory -like '*'} -Properties SIDHistory -Server $dc
foreach($u in $sh){foreach($sid in $u.SIDHistory){$rid=($sid -split '-')[-1]
if($rid -in @('500','502','512','516','518','519','520')){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='SIDHistory';Severity='CRITICAL';Description="[$dom] SIDHistory priv RID: $($u.SamAccountName)";Detail="SID:$sid RID:$rid";Remediation='Remove SIDHistory';MitreID='T1134.005'});$script:GS.Crit++;$script:GS.Findings++;Write-A 'CRITICAL' "[$dom] SIDHist: $($u.SamAccountName)"}}}}catch{Write-Warning "SIDHistory: $_"}
try{$npa=Get-ADUser -Filter{DoesNotRequirePreAuth -eq $true -and Enabled -eq $true} -Properties AdminCount -Server $dc
foreach($u in $npa){$sv=if($u.AdminCount -eq 1){'CRITICAL'}else{'HIGH'}
$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='ASREP';Severity=$sv;Description="[$dom] ASREP: $($u.SamAccountName)";Detail="AC:$($u.AdminCount)";Remediation='Enable PreAuth';MitreID='T1558.004'});$script:GS.Findings++;if($sv -eq 'CRITICAL'){$script:GS.Crit++}else{$script:GS.High++};Write-A $sv "[$dom] ASREP: $($u.SamAccountName)"
if($Mode -eq 'Remediate'){Set-ADAccountControl $u -DoesNotRequirePreAuth $false -Server $dc;$null=$script:RL.Add("AD|[$dom] PreAuth: $($u.SamAccountName)");$script:GS.Fixes++}}}catch{Write-Warning "ASREP: $_"}
try{$uc=Get-ADComputer -Filter{TrustedForDelegation -eq $true -and Enabled -eq $true} -Properties TrustedForDelegation -Server $dc|?{$_.Name -ne(Get-ADDomainController -Discover -DomainName $dom).Name}
foreach($c in $uc){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Deleg';Severity='HIGH';Description="[$dom] Unconstrained: $($c.Name)";Detail='TGT in memory';Remediation='Use RBCD';MitreID='T1558'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] Uncon: $($c.Name)"}}catch{Write-Warning "Deleg: $_"}
try{$tr=Get-ADTrust -Filter * -Server $dc -EA SilentlyContinue
foreach($t in $tr){if(-not $t.SIDFilteringQuarantined -and $t.TrustDirection -ne 'Outbound'){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Trust';Severity='HIGH';Description="[$dom] SIDFilter off: $($t.Target)";Detail="Dir:$($t.TrustDirection)";Remediation='netdom /quarantine:yes';MitreID='T1134.005'});$script:GS.High++;$script:GS.Findings++;Write-A 'HIGH' "[$dom] SIDFilt: $($t.Target)"}}}catch{Write-Warning "Trust: $_"}
try{$pu=@(Get-ADGroupMember 'Protected Users' -Server $dc -EA SilentlyContinue)
$da=@(Get-ADGroupMember 'Domain Admins' -Server $dc -EA SilentlyContinue)
$np=$da|?{$_.SamAccountName -notin $pu.SamAccountName}
if($np){$null=$script:AF.Add([PSCustomObject]@{Host='AD-Wide';Role='Domain';Timestamp=Get-Date -F 'yyyy-MM-dd HH:mm:ss';Category='Config';Severity='MEDIUM';Description="[$dom] DA not in ProtectedUsers: $(($np.SamAccountName)-join', ')";Detail='';Remediation='Add to Protected Users';MitreID='T1550'});$script:GS.Med++;$script:GS.Findings++;Write-A 'MEDIUM' "[$dom] DA !PU: $(@($np).Count)"}}catch{Write-Warning "PU: $_"}
}
}
#endregion
#region ═══ REPORT ═══
function Export-Report{
Write-B "ОТЧЁТ"
if(-not(Test-Path $OutputPath)){New-Item $OutputPath -ItemType Directory -Force|Out-Null}
$rf=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').html"
$af=@($script:AF)|Sort @{E={switch($_.Severity){'CRITICAL'{0}'HIGH'{1}'MEDIUM'{2}'LOW'{3}default{4}}}},Host
$hg=$af|Group Host|Sort @{E={($_.Group|%{switch($_.Severity){'CRITICAL'{0}'HIGH'{1}'MEDIUM'{2}default{3}}}|Measure -Min).Minimum}}
$tr=foreach($f in $af){$rc=switch($f.Severity){'CRITICAL'{'sc'}'HIGH'{'sh'}'MEDIUM'{'sm'}default{'sl'}};"<tr class='$rc'><td>$($f.Timestamp)</td><td>$($f.Host)</td><td>$($f.Role)</td><td><span class='b $rc'>$($f.Severity)</span></td><td>$($f.Category)</td><td>$($f.Description)</td><td class='d'>$($f.Detail)</td><td>$($f.Remediation)</td><td><code>$($f.MitreID)</code></td></tr>"}
$hr=foreach($h in $hg){$c=@($h.Group|?{$_.Severity -eq 'CRITICAL'}).Count;$hi=@($h.Group|?{$_.Severity -eq 'HIGH'}).Count;$m=@($h.Group|?{$_.Severity -eq 'MEDIUM'}).Count;$rl=($h.Group|Select -First 1).Role;$cl=if($c){'sc'}elseif($hi){'sh'}elseif($m){'sm'}else{'sl'};"<tr class='$cl'><td>$($h.Name)</td><td>$rl</td><td>$c</td><td>$hi</td><td>$m</td><td>$($h.Count)</td></tr>"}
$rr=foreach($r in $script:RL){"<tr><td>$r</td></tr>"}
$el=(Get-Date)-$script:T0
$html=@"
<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><title>InfraAudit $(Get-Date -F dd.MM.yyyy)</title>
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Segoe UI',system-ui,sans-serif;background:#080c14;color:#c9d1d9;padding:16px}.w{max-width:1600px;margin:0 auto}h1{color:#58a6ff;font-size:1.5em}h2{color:#8b949e;margin:28px 0 12px;border-bottom:1px solid #21262d;padding-bottom:6px}.mt{color:#6e7681;font-size:.85em;margin-bottom:20px}.gr{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin:16px 0}.cd{padding:14px;border-radius:6px;text-align:center}.cd .n{font-size:2em;font-weight:700}.cd .l{font-size:.78em;color:#8b949e}.ct{background:#161b22;border:1px solid #30363d}.ct .n{color:#58a6ff}.cs{background:#0d2818;border:1px solid #23883680}.cs .n{color:#3fb950}.cu{background:#1c1207;border:1px solid #9e6a0380}.cu .n{color:#d29922}.cc{background:#3d1418;border:1px solid #f8514940}.cc .n{color:#f85149}.ch{background:#341a04;border:1px solid #d2992240}.ch .n{color:#d29922}.cm{background:#0d1d31;border:1px solid #58a6ff40}.cm .n{color:#58a6ff}.cf{background:#0d1525;border:1px solid #388bfd40}.cf .n{color:#388bfd}table{width:100%;border-collapse:collapse;font-size:.8em;margin-top:8px}th{background:#0d1117;color:#58a6ff;padding:8px 6px;text-align:left;position:sticky;top:0;border-bottom:2px solid #21262d}td{padding:6px;border-bottom:1px solid #161b22;vertical-align:top}tr:hover{background:#161b2266}.b{padding:1px 6px;border-radius:3px;font-weight:600;font-size:.78em}.sc{color:#f85149}.sc .b{background:#3d1418}.sh{color:#d29922}.sh .b{background:#341a04}.sm{color:#58a6ff}.sm .b{background:#0d1d31}.sl{color:#8b949e}.d{max-width:250px;overflow-wrap:break-word;font-size:.82em;color:#6e7681}code{background:#161b22;padding:1px 4px;border-radius:2px;color:#79c0ff}.tb{display:flex;gap:0;margin:20px 0 0;border-bottom:2px solid #21262d}.t{padding:8px 18px;cursor:pointer;color:#8b949e;border-bottom:2px solid transparent;margin-bottom:-2px;font-size:.9em}.t.a{color:#58a6ff;border-bottom-color:#58a6ff}.p{display:none}.p.a{display:block}.ft{text-align:center;color:#484f58;font-size:.75em;margin-top:40px}</style></head>
<body><div class=w><h1>AD Infrastructure Security Audit</h1>
<div class=mt>Domains: $($Domains-join', ') | Mode: $Mode | Depth: ${HoursBack}h | $(Get-Date -F 'dd.MM.yyyy HH:mm') | $($el.ToString('hh\:mm\:ss'))</div>
<div class=gr><div class="cd ct"><div class=n>$($script:GS.Total)</div><div class=l>Total</div></div><div class="cd cs"><div class=n>$($script:GS.Scanned)</div><div class=l>Scanned</div></div><div class="cd cu"><div class=n>$($script:GS.Unreach)</div><div class=l>N/A</div></div><div class="cd cc"><div class=n>$($script:GS.Crit)</div><div class=l>CRIT</div></div><div class="cd ch"><div class=n>$($script:GS.High)</div><div class=l>HIGH</div></div><div class="cd cm"><div class=n>$($script:GS.Med)</div><div class=l>MED</div></div><div class="cd cf"><div class=n>$($script:GS.Fixes)</div><div class=l>FIX</div></div></div>
<div class=tb><div class="t a" onclick="st('h')">Hosts</div><div class=t onclick="st('f')">Findings</div><div class=t onclick="st('r')">Remediation</div><div class=t onclick="st('g')">Hardening</div></div>
<div id=th class="p a"><h2>By Host</h2><table><thead><tr><th>Host</th><th>Role</th><th>C</th><th>H</th><th>M</th><th>All</th></tr></thead><tbody>$($hr-join"`n")</tbody></table></div>
<div id=tf class=p><h2>Findings ($($script:GS.Findings))</h2><table><thead><tr><th>Time</th><th>Host</th><th>Role</th><th>Sev</th><th>Cat</th><th>Desc</th><th>Detail</th><th>Fix</th><th>MITRE</th></tr></thead><tbody>$($tr-join"`n")</tbody></table></div>
<div id=tr class=p><h2>Remediation ($($script:GS.Fixes))</h2>$(if($script:RL.Count){"<table><thead><tr><th>Action</th></tr></thead><tbody>$($rr-join"`n")</tbody></table>"}else{"<p style='color:#8b949e;padding:20px'>Mode: $Mode</p>"})</div>
<div id=tg class=p><h2>Hardening</h2><table><thead><tr><th>Control</th><th>Description</th><th>P</th></tr></thead><tbody><tr><td><b>LSA Protection</b></td><td>RunAsPPL=1</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Credential Guard</b></td><td>Hardware isolation</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Protected Users</b></td><td>DA/EA/SA</td><td><span class="b sc">P1</span></td></tr><tr><td><b>Tiered Admin</b></td><td>T0/T1/T2</td><td><span class="b sc">P1</span></td></tr><tr><td><b>AES Kerberos</b></td><td>Disable RC4</td><td><span class="b sh">P2</span></td></tr><tr><td><b>NTLM Restrict</b></td><td>Audit then Deny</td><td><span class="b sh">P2</span></td></tr><tr><td><b>gMSA</b></td><td>SPN accounts</td><td><span class="b sh">P2</span></td></tr><tr><td><b>krbtgt</b></td><td>Double reset 90-180d</td><td><span class="b sh">P2</span></td></tr><tr><td><b>Sysmon+WEF</b></td><td>To SIEM</td><td><span class="b sh">P2</span></td></tr><tr><td><b>PS Logging</b></td><td>ScriptBlock+Module</td><td><span class="b sm">P3</span></td></tr><tr><td><b>LAPS</b></td><td>Local admin rotation</td><td><span class="b sm">P3</span></td></tr><tr><td><b>Spooler</b></td><td>Off on DC/servers</td><td><span class="b sm">P3</span></td></tr><tr><td><b>SMBv1</b></td><td>Disable</td><td><span class="b sm">P3</span></td></tr><tr><td><b>SID Filtering</b></td><td>All external trusts</td><td><span class="b sm">P3</span></td></tr></tbody></table></div>
<div class=ft>$(Get-Date -F 'dd.MM.yyyy HH:mm') | $($script:GS.Scanned)/$($script:GS.Total) | $($script:GS.Findings) findings | $($el.ToString('hh\:mm\:ss'))</div>
</div><script>function st(i){document.querySelectorAll('.t').forEach(t=>t.classList.remove('a'));document.querySelectorAll('.p').forEach(t=>t.classList.remove('a'));document.getElementById('t'+i).classList.add('a');event.target.classList.add('a')}</script></body></html>
"@
$html|Out-File $rf -Encoding UTF8 -Force;Write-G "HTML: $rf"
$csv=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').csv"
@($af)|Export-Csv $csv -NoTypeInformation -Encoding UTF8;Write-G "CSV: $csv"
$json=Join-Path $OutputPath "InfraAudit_$(Get-Date -F 'yyyyMMdd_HHmmss').json"
@{meta=@{time=Get-Date -F 'o';domains=$Domains;mode=$Mode;hours=$HoursBack;dur=[int]$el.TotalSeconds;stats=$script:GS};findings=@($af);fixes=@($script:RL)}|ConvertTo-Json -Depth 5|Out-File $json -Encoding UTF8;Write-G "JSON: $json"
}
#endregion
#region ═══ MAIN ═══
Write-Host "`n AD INFRASTRUCTURE SECURITY SCANNER v1.1" -ForegroundColor Cyan
Write-Host " $(Get-Date -F 'dd.MM.yyyy HH:mm:ss') | Mode: $Mode`n" -ForegroundColor DarkGray
try{
Import-Module ActiveDirectory -EA Stop
if(-not(Test-Path $OutputPath)){New-Item $OutputPath -ItemType Directory -Force|Out-Null}
if($Mode -eq 'Report'){
$lj=Get-ChildItem $OutputPath -Filter '*.json' -EA SilentlyContinue|Sort LastWriteTime -Desc|Select -First 1
if($lj){$dt=Get-Content $lj.FullName -Raw|ConvertFrom-Json;foreach($f in $dt.findings){$null=$script:AF.Add($f)};$script:GS=$dt.meta.stats;Write-G "Loaded $(@($dt.findings).Count) from $($lj.Name)"}
else{Write-Warning "No previous data. Full scan.";$Mode='Detect'}
}
if($Mode -ne 'Report'){Start-Scan;Test-ADWide}
Export-Report
Write-B "ИТОГО"
Write-Host " Hosts: $($script:GS.Scanned)/$($script:GS.Total) (n/a: $($script:GS.Unreach))" -ForegroundColor White
Write-Host " CRITICAL: $($script:GS.Crit)" -ForegroundColor Red
Write-Host " HIGH: $($script:GS.High)" -ForegroundColor Magenta
Write-Host " MEDIUM: $($script:GS.Med)" -ForegroundColor DarkYellow
Write-Host " Fixes: $($script:GS.Fixes)" -ForegroundColor Blue
Write-Host " Time: $((Get-Date)-$script:T0|%{$_.ToString('hh\:mm\:ss')})" -ForegroundColor White
Write-Host " Reports: $OutputPath`n" -ForegroundColor Cyan
if($script:GS.Crit -gt 0){Write-Host " *** CRITICAL IoC DETECTED — INVESTIGATE IMMEDIATELY ***`n" -ForegroundColor Red}
}catch{Write-Error "Fatal: $_";Write-Error $_.ScriptStackTrace;exit 1}
#endregion