Python и Bash для ИБ: пять скриптов для регулярных проверок

Проверки вида «кто слушает порт», «когда истекает сертификат», «появился ли новый SUID-файл» обычно делают руками раз в квартал и забывают. Ниже пять коротких скриптов, которые читают состояние сервера, сравнивают его с эталоном и возвращают код выхода, и способ запускать их по таймеру с отправкой результата в мониторинг.
Скрипты ничего не меняют в системе: они только читают и сообщают. Автоматическое «исправление» (бан адресов, снятие SUID) здесь сознательно не делается: ошибка в такой автоматике блокирует доступ или ломает службы.
Правила, чтобы скрипт не стал проблемой
- Только чтение. Проверка не пишет в конфигурацию и не перезапускает службы.
- Коды выхода. Единое соглашение:
0норма,1отклонение,2критично или ошибка проверки,3нет данных (например, нет эталона). По коду работают таймер и мониторинг. - Эталон хранится отдельно от скрипта и принадлежит root. Обновлять его нужно вручную, после разбора причины, а не автоматически: иначе проверка перестанет находить то, ради чего создана.
- Права. Скрипты в
/usr/local/lib/ib-checks/, владелец root, запись только владельцу (chmod 755для каталога и750для скриптов). Файлы, которые читает не-root, не должны быть доступны для записи ему. - Секреты не в скриптах. Токены и пароли только через переменные окружения unit-файла или файл с правами
600: секреты в git. - Статический анализ. Перед использованием прогоняйте
bash -nиshellcheckдля Bash,python3 -m py_compileдля Python.
1. Слушающие сокеты против эталона (Bash)
Самое полезное и самое простое: сравнить то, что слушает сеть, с тем, что должно слушать. Эталон хранит и адрес, и порт: если служба с 127.0.0.1:8080 переехала на 0.0.0.0:8080, это тоже отклонение. Как читать вывод ss: шпаргалка ss, ps, lsof.
sudo mkdir -p /etc/ib-checks /var/lib/ib-checks /usr/local/lib/ib-checks
sudo chmod 700 /etc/ib-checks /var/lib/ib-checks
# эталон: фиксируем текущее состояние ПОСЛЕ ручной проверки, что оно правильное
ss -H -tuln | awk '{print $1" "$5}' | sort -u | sudo tee /etc/ib-checks/ports.allow
Скрипт /usr/local/lib/ib-checks/ports_check.sh:
#!/usr/bin/env bash
# Сверка слушающих сокетов с эталоном. Только чтение. Код 0 = совпало, 1 = есть отклонения.
set -euo pipefail
ALLOW=/etc/ib-checks/ports.allow
[ -r "$ALLOW" ] || { echo "UNKNOWN нет файла эталона $ALLOW"; exit 3; }
current=$(ss -H -tuln | awk '{print $1" "$5}' | sort -u)
unexpected=$(comm -23 <(printf '%s\n' "$current") <(sort -u "$ALLOW") || true)
missing=$(comm -13 <(printf '%s\n' "$current") <(sort -u "$ALLOW") || true)
if [ -n "$unexpected" ]; then
echo "FAIL неожиданные сокеты (нет в эталоне):"; echo "$unexpected"
fi
if [ -n "$missing" ]; then
echo "WARN из эталона не слушают (служба остановлена?):"; echo "$missing"
fi
[ -z "$unexpected" ] && { echo "OK слушающие сокеты совпадают с эталоном"; exit 0; }
exit 1
Пример вывода при появлении постороннего сокета:
FAIL неожиданные сокеты (нет в эталоне):
tcp 127.0.0.1:8099
WARN из эталона не слушают (служба остановлена?):
tcp 0.0.0.0:443
Что делать с найденным: сначала выяснить процесс (ss -tulpn), затем закрыть порт правилами firewall (nftables и firewalld) или внести в эталон с записью причины. Проверка снаружи в дополнение: Nmap.
2. Срок TLS-сертификатов (Python)
Просроченный сертификат останавливает сайт и API так же, как падение сервера. Скрипт подключается как обычный клиент, проверяет цепочку и имя и считает дни до окончания. Если проверка не проходит, это тоже отклонение: клиенты увидят то же самое.
#!/usr/bin/env python3
"""Сколько дней осталось у TLS-сертификата. Использование: cert_days.py host[:port] ...
Коды выхода: 0 всё хорошо, 1 меньше 21 дня, 2 меньше 7 дней или ошибка соединения/проверки."""
import datetime
import socket
import ssl
import sys
WARN_DAYS, CRIT_DAYS = 21, 7
def days_left(host, port=443):
ctx = ssl.create_default_context() # проверяет цепочку и имя хоста
with socket.create_connection((host, port), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
cert = tls.getpeercert()
expires = datetime.datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z').replace(tzinfo=datetime.timezone.utc)
return (expires - datetime.datetime.now(datetime.timezone.utc)).days
def main(targets):
worst = 0
for t in targets:
host, _, port = t.partition(':')
try:
d = days_left(host, int(port or 443))
except (OSError, ssl.SSLError, ValueError) as e:
print(f'FAIL {t}: {e}')
worst = 2
continue
level = 2 if d < CRIT_DAYS else 1 if d < WARN_DAYS else 0
print(f"{['OK ', 'WARN', 'CRIT'][level]} {t}: осталось {d} дн.")
worst = max(worst, level)
return worst
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]) if len(sys.argv) > 1 else print(__doc__) or 2)
python3 /usr/local/lib/ib-checks/cert_days.py www.example.com mail.example.com:465
Для одной проверки без Python достаточно openssl: код выхода 1, если сертификат истекает в ближайшие 14 дней (1209600 секунд):
echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null | openssl x509 -noout -checkend 1209600
Настройка самих сертификатов и TLS: HTTPS в nginx.
3. Неудачные входы по SSH (Python)
Скрипт читает журнал со стандартного ввода и показывает, с каких адресов и под какими именами подбирают пароль. Он только считает: решение о блокировке принимает человек или CrowdSec (защита SSH и nginx), а не скрипт.
#!/usr/bin/env python3
"""Топ источников неудачных входов SSH из журнала на stdin.
journalctl -u ssh --since "24 hours ago" --no-pager | failed_logins.py --top 10 --threshold 20
Код выхода 1, если у какого-то адреса попыток не меньше порога."""
import argparse
import collections
import re
import sys
RX = re.compile(r'Failed password for (?:invalid user )?(?P<user>\S+) from (?P<ip>[0-9A-Fa-f:.]+)')
ap = argparse.ArgumentParser()
ap.add_argument('--top', type=int, default=10)
ap.add_argument('--threshold', type=int, default=20)
args = ap.parse_args()
by_ip, by_user = collections.Counter(), collections.Counter()
for line in sys.stdin:
m = RX.search(line)
if m:
by_ip[m['ip']] += 1
by_user[m['user']] += 1
total = sum(by_ip.values())
print(f'Неудачных входов: {total}, адресов: {len(by_ip)}')
print('Адреса:')
for ip, n in by_ip.most_common(args.top):
print(f' {n:6d} {ip}')
print('Имена пользователей:')
for u, n in by_user.most_common(args.top):
print(f' {n:6d} {u}')
sys.exit(1 if by_ip and by_ip.most_common(1)[0][1] >= args.threshold else 0)
journalctl -u ssh --since "24 hours ago" --no-pager | python3 /usr/local/lib/ib-checks/failed_logins.py --top 5 --threshold 50
Имя юнита зависит от дистрибутива: ssh в Debian и Ubuntu, sshd в RHEL-семействе. Если журнал отправляется на отдельный сервер (сбор логов), запускайте скрипт там по файлу журнала: python3 failed_logins.py < /var/log/remote/web01/sshd.log.
4. Новые SUID/SGID-файлы (Bash)
Файл с битом SUID запускается с правами владельца, чаще всего root. Появление нового такого файла — классический признак закрепления или ошибки установки. Эталон: список после чистой установки и ручной проверки (список мер по файловым правам: рекомендации ФСТЭК по настройке Linux).
sudo find / -xdev -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null | sort | sudo tee /var/lib/ib-checks/suid.base
Скрипт /usr/local/lib/ib-checks/suid_diff.sh:
#!/usr/bin/env bash
# Новые и пропавшие SUID/SGID-файлы относительно эталона. Только чтение. Код 1 при отличиях.
set -euo pipefail
BASE=/var/lib/ib-checks/suid.base
now=$(mktemp)
trap 'rm -f "$now"' EXIT
# find может вернуть ненулевой код (недоступные каталоги): это не ошибка проверки
{ find / -xdev -type f \( -perm -4000 -o -perm -2000 \) 2>/dev/null || true; } | sort > "$now"
[ -s "$BASE" ] || { echo "UNKNOWN нет эталона $BASE"; exit 3; }
if diff -u "$BASE" "$now"; then
echo "OK список SUID/SGID не изменился"
else
echo "FAIL список SUID/SGID изменился (строки + это новые файлы)"
exit 1
fi
Ключ -xdev не заходит на другие файловые системы: если у вас отдельные разделы с исполняемыми файлами (/usr, /opt), добавьте их в команду отдельным аргументом пути, иначе проверка их не увидит.
5. Учётные записи (Python)
Проверка ловит три типовых проблемы: лишний пользователь с UID 0, пустой пароль и давно не менявшийся пароль у учётных записей с оболочкой. Заблокированные записи (!, *) и записи без входа пропускаются. Нужен root для чтения /etc/shadow. Для входа по ключам возраст пароля не критичен, поэтому порог настраивается.
#!/usr/bin/env python3
"""Проверка учётных записей: лишний UID 0, пустые пароли, давно не менявшиеся пароли у входящих по паролю.
Нужен root для чтения /etc/shadow. Использование: accounts_check.py [--max-age 365] [--passwd FILE] [--shadow FILE]"""
import argparse
import datetime
import sys
ap = argparse.ArgumentParser()
ap.add_argument('--max-age', type=int, default=365)
ap.add_argument('--passwd', default='/etc/passwd')
ap.add_argument('--shadow', default='/etc/shadow')
a = ap.parse_args()
NOLOGIN = ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false', '/usr/bin/false')
problems = []
users = {}
for line in open(a.passwd, encoding='utf-8'):
f = line.rstrip('\n').split(':')
if len(f) >= 7:
users[f[0]] = {'uid': int(f[2]), 'shell': f[6]}
for name, u in users.items():
if u['uid'] == 0 and name != 'root':
problems.append(f'UID 0 у учётной записи {name}')
today = (datetime.datetime.now(datetime.timezone.utc) - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)).days
for line in open(a.shadow, encoding='utf-8'):
f = line.rstrip('\n').split(':')
if len(f) < 3 or f[0] not in users:
continue
name, pw, lastchg = f[0], f[1], f[2]
if pw == '':
problems.append(f'пустой пароль у {name}')
continue
if pw[0] in '!*' or users[name]['shell'] in NOLOGIN:
continue # заблокирована или без оболочки
if lastchg.isdigit() and today - int(lastchg) > a.max_age:
problems.append(f'пароль {name} не менялся {today - int(lastchg)} дн.')
if problems:
print('FAIL')
for p in problems:
print(' ' + p)
sys.exit(1)
print('OK учётные записи в порядке')
Привилегии администраторов отдельно: sudo и sudoers.
Запуск по расписанию и передача результата
Скрипты запускает один «обёрточный» сценарий. Он пишет итог в журнал (logger) и отправляет код выхода в Zabbix. В Zabbix для каждой проверки создайте элемент данных типа Zabbix trapper с ключами ib.ports, ib.suid, ib.accounts, ib.cert и триггер на значение, отличное от нуля. Имя узла в -s должно совпадать с именем в Zabbix. Синтаксис выражений триггеров зависит от версии, сверьтесь с документацией.
#!/usr/bin/env bash
# /usr/local/lib/ib-checks/run-all.sh
set -u
HOST=$(hostname -f)
DIR=/usr/local/lib/ib-checks
run() { # run ИМЯ КОМАНДА [АРГУМЕНТЫ...]
local name=$1; shift
local out rc
out=$("$@" 2>&1); rc=$?
logger -t ib-checks -p auth.notice "$name rc=$rc"
[ "$rc" -ne 0 ] && printf '%s\n' "$out" | logger -t ib-checks -p auth.warning
zabbix_sender -z ZABBIX_SERVER -s "$HOST" -k "ib.$name" -o "$rc" >/dev/null
return 0
}
run ports "$DIR/ports_check.sh"
run suid "$DIR/suid_diff.sh"
run accounts "$DIR/accounts_check.py"
run cert "$DIR/cert_days.py" www.example.com
Запуск по таймеру systemd вместо cron: есть журнал, ограничения и учёт неудачных запусков. Файл /etc/systemd/system/ib-checks.service:
[Unit]
Description=Регулярные проверки ИБ
[Service]
Type=oneshot
ExecStart=/usr/local/lib/ib-checks/run-all.sh
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
Файл /etc/systemd/system/ib-checks.timer:
[Unit]
Description=Ежедневный запуск проверок ИБ
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now ib-checks.timer
systemctl list-timers ib-checks.timer
sudo systemctl start ib-checks.service
journalctl -t ib-checks -n 20 --no-pager
Службе нужны только чтение и сеть до Zabbix. Оценку ограничений покажет systemd-analyze security ib-checks.service. Что ещё настроить в Zabbix: мониторинг Linux и Windows.
Диагностика
| Симптом | Причина и что делать |
|---|---|
| Сокеты: FAIL на случайных UDP-портах | Клиентские сокеты (DHCP, chrony) появляются и исчезают. Внесите нужные в эталон или исключите клиентские UDP-порты в awk |
ss: invalid option -- 'H' |
Старый iproute2. Уберите -H и добавьте | tail -n +2 после ss |
| FAIL после запуска Docker | Контейнеры публикуют порты. Внесите осознанно в эталон и привяжите служебные порты к 127.0.0.1: безопасность Docker |
Permission denied: '/etc/shadow' |
Проверка учётных записей запущена без root |
CERTIFICATE_VERIFY_FAILED |
Цепочка или имя не проходят проверку: это реальная проблема сертификата, а не скрипта. Для внутреннего удостоверяющего центра добавьте его корневой сертификат в хранилище системы |
| Неудачные входы: нули | Другое имя юнита (sshd вместо ssh) или нестандартный формат журнала: проверьте строку вручную через journalctl |
| SUID: FAIL после обновления пакетов | Пакет заменил файл. Изучите diff, и только после ручной проверки обновите эталон |
Как проверить
Проверьте, что скрипты действительно ловят отклонения, на безопасных искусственных примерах.
# 1. посторонний сокет
python3 -m http.server 8099 --bind 127.0.0.1 &
sudo /usr/local/lib/ib-checks/ports_check.sh; echo "код=$?" # ожидается FAIL и код 1
kill %1
sudo /usr/local/lib/ib-checks/ports_check.sh; echo "код=$?" # ожидается OK и код 0
# 2. новый SUID-файл
sudo cp /bin/true /usr/local/bin/suid-test && sudo chmod u+s /usr/local/bin/suid-test
sudo /usr/local/lib/ib-checks/suid_diff.sh; echo "код=$?" # ожидается FAIL, в diff строка +/usr/local/bin/suid-test
sudo rm /usr/local/bin/suid-test
# 3. сертификат собственного сервера
python3 /usr/local/lib/ib-checks/cert_days.py www.example.com; echo "код=$?"
# 4. учётные записи
sudo python3 /usr/local/lib/ib-checks/accounts_check.py; echo "код=$?"
Для неудачных входов выполните попытку входа под несуществующим именем с тестовой машины и убедитесь, что адрес появился в выводе. Затем запустите ib-checks.service вручную и проверьте, что в Latest data узла в Zabbix пришли значения четырёх ключей. Если статуса в Zabbix нет, смотрите journalctl -t ib-checks и вывод zabbix_sender без перенаправления в /dev/null.
Дальше по теме: управление уязвимостями, где найденные отклонения превращаются в задачи со сроками.
Источники
- ss(8), man-страница iproute2. URL: man7.org/linux/man-pages/man8/ss.8.html.
- systemd.timer. URL: freedesktop.org.
- Zabbix, zabbix_sender. URL: zabbix.com/documentation.
- ShellCheck, статический анализатор для shell. URL: shellcheck.net.






















