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

0
18
Обложка статьи: 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.

Цикл регулярной проверки безопасностиТаймер запускает скрипты, они сравнивают состояние с эталоном и возвращают код выхода; результат уходит в Zabbix и в журнал, дальше разбор администратором.Таймер systemdраз в суткиСкрипты проверкитолько чтениеЭталонфайлы с «как должно быть»Результаткод выхода: 0 норма, 1 отклонение,2 критично, 3 нет данныхZabbixтриггер, если значение не 0Журналlogger, затем сервер логовАдминистраторисправить или обновить эталон
Скрипты ничего не меняют: решение об исправлении или обновлении эталона принимает человек.

Запуск по расписанию и передача результата

Скрипты запускает один «обёрточный» сценарий. Он пишет итог в журнал (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.

Дальше по теме: управление уязвимостями, где найденные отклонения превращаются в задачи со сроками.

Источники

Комментарии закрыты.