SSH-ключ уволенного сотрудника работал ещё восемь месяцев
Увольнение прошло штатно: доступ в Keycloak отключили в день заявления, из VPN вычеркнули, из репозиториев выпилили. А через восемь месяцев внешний аудит нашёл живой SSH-ключ бывшего инженера на трёх десятках продовых серверов — и оказалось, что им действительно пользовались. Ниже — честный разбор: что мы увидели в логах, какие версии проверяли и отбрасывали, и в чём была реальная причина, из-за которой ручное удаление доступа не сработало.
Содержание
Как это вообще нашли
Триггером стал не сигнал тревоги, а рутина: один из клиентов, для которого мы обслуживаем инфраструктуру приёма платежей, запросил перед продлением контракта список всех, у кого есть доступ к продовым серверам, с датой последнего входа по каждому. До этого момента у нас не было единого реестра — доступ раздавался годами, кто-то через bastion с аутентификацией в Keycloak, кто-то напрямую по authorized_keys, который раскатывал Ansible.
Security-инженер написал простой скрипт: прошёлся по Ansible-инвентарю (42 хоста), на каждом вызвал ssh-keygen -lf для authorized_keys и собрал fingerprints в одну таблицу.
#!/usr/bin/env bash
# fingerprints.sh — собрать все ключи с продовых хостов
while read -r host; do
ssh -o BatchMode=yes "$host" \
"sudo awk '{print \$0}' /home/*/.ssh/authorized_keys 2>/dev/null" \
| while read -r line; do
fp=$(echo "$line" | ssh-keygen -lf /dev/stdin 2>/dev/null)
echo -e "$host\t$fp\t$line"
done
done < inventory_hosts.txt > all_keys.tsv
Результат сверили с таблицей действующих сотрудников из HR-системы (экспорт по API, поле email совпадает с комментарием в ключе вида user@host). Расхождение нашлось быстро: fingerprint SHA256:k9F3... встречался на 39 серверах из 42, а владелец — инженер, который уволился в конце декабря 2025 года, почти восемь месяцев назад. Учётная запись в Keycloak была деактивирована в день увольнения, доступ в VPN отозван, из GitLab он был удалён — но в authorized_keys на большей части серверов ключ преспокойно лежал.
Что показали логи
Дальше вопрос был не «есть ли ключ», а «пользовались ли им». На хостах, где включён auditd, и там, где просто хватило journalctl, подняли историю по fingerprint за весь период:
journalctl -u sshd --since "2025-12-01" | grep -F "SHA256:k9F3"
На части старых серверов systemd-журнала на такую глубину не было — пришлось поднимать /var/log/auth.log.*.gz и логи из архива на сервере централизованного логирования, куда часть хостов всё же лила auth.log через rsyslog. Собрали таймлайн:
- 20 декабря 2025 — последний вход через bastion (штатно, ещё в статусе сотрудника).
- 22 декабря 2025 — HR фиксирует увольнение, Keycloak отключает учётку, доступ к bastion пропадает (
Failed publickeyв логах bastion с этой даты — подтверждает, что здесь офбординг сработал). - С 8 января по 14 августа 2026 — 23 успешных входа напрямую на внутренние серверы (в обход bastion, по приватной сети, куда раньше открывали VPN-доступ на время дежурств), с интервалом от нескольких дней до трёх недель, всегда в вечернее время или на выходных.
- Все сессии — интерактивные (
Accepted publickey ... ttyname=/dev/pts/0), в среднем 4–12 минут.
По истории команд на нескольких хостах (bash history сохранялась, потому что серверы не переводили пользователя в nologin) видно, что действия были рабочими, а не разрушительными: проверка systemctl status, просмотр логов nginx, пару раз рестарт зависшего воркера, один раз — выгрузка одной таблицы через psql \copy. Ничего похожего на попытку скрыть следы или massово выгрести данные.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверВерсии, которые проверили и отбросили
Первая реакция — предположить компрометацию: может, ключ утёк и им пользуется кто-то другой. Проверили три гипотезы.
Ключ скомпрометирован и используется третьим лицом. Сверили IP входов: почти все — из одной подсети домашнего провайдера, географически совпадающей с городом, где сотрудник жил (это было известно из анкеты при выдаче корпоративного VPN два года назад). Стиль работы в терминале — те же алиасы в .bashrc, та же последовательность команд при диагностике, которую он использовал и раньше. Версию не подтвердили: похоже, что ключом пользовался именно он, не третье лицо.
Ключ на самом деле сервисный, а не персональный. В компании сервисные ключи именуются с префиксом svc- и хранятся в Vault, а не в личных .ssh. Этот ключ лежал в /home/dmitry/.ssh/authorized_keys с комментарием dmitry@dmitry-thinkpad, то есть однозначно персональный. Версию отбросили.
Это автоматика — Ansible или деплой-бот переиспользуют ключ. Проверили: у деплой-пайплайна отдельный сервисный ключ deploy-bot, с другим fingerprint, и его сессии в логах всегда без выделения tty (batch-режим), а не интерактивные. Совпадений по времени с деплоями тоже не было — входы приходились на вечера и выходные, деплои идут в рабочие часы по будням. Версию отбросили.
Кто-то в команде специально держал доступ «на всякий случай» под его именем. Опросили всех, кто мог знать: никто не признался, никто не подтвердил осознанное использование. В HR-системе и в Keycloak учётная запись однозначно помечена как уволенная с 22 декабря. Версию отбросили — похоже, дело не в чьём-то умысле оставить лазейку, а в процессе, который просто не добрался до всех серверов.
Настоящая причина
Полгода назад bastion-хост перевели на аутентификацию через Keycloak с выпуском коротких SSH-сертификатов (TTL 12 часов) — именно поэтому доступ через bastion действительно оборвался день в день с увольнением. Но «внутренние» серверы, куда раньше пускали напрямую по VPN на время дежурств, остались на классической схеме: authorized_keys раскатывался Ansible-плейбуком из приватного репозитория ops-infra, где список пользователей и их публичных ключей хранится в group_vars/all/users.yml. На части хостов этот плейбук запускается вручную при изменениях, а на 39 из 42 серверов висел ночной systemd-таймер с ansible-pull, который синхронизировал состояние с репозиторием каждые сутки.
При офбординге инженер, отвечавший за отзыв доступа, вручную зашёл на три сервера, которые вспомнил как критичные, и удалил строку из authorized_keys руками — репозиторий ops-infra не тронул, потому что не знал о нём или посчитал, что это не входит в его зону ответственности. PR на удаление ключа из users.yml завели, но не назначили ответственного и не смержили — задача повисела в трекере и потерялась среди других тикетов.
Дальше сработала классика infrastructure-as-code, которая одинаково больно бьёт что при добавлении, что при удалении доступа: источник правды — git-репозиторий — не поменялся, а автоматика слепо доверяет источнику правды. Каждую ночь ansible-pull тихо возвращал строку обратно в authorized_keys — в том числе и на тех трёх серверах, где её убрали вручную. На остальных 36 серверах ключ вообще никто не трогал: их не было в списке «критичных», который держали в голове, а не в документации.
# group_vars/all/users.yml — вот эта строка пережила офбординг на 8 месяцев
- username: dmitry
ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... dmitry@dmitry-thinkpad"
groups: [sudo, docker]
state: present # никто не поменял на absent и не смержил PR
Итог: ручное удаление доступа без правки источника правды в IaC не удаление, а временная маскировка проблемы, которая возвращается по расписанию.
Что сделали в первые часы
Как только подтвердили масштаб, действовали параллельно по трём направлениям.
- Закрыли доступ на источнике. Поменяли
state: presentнаabsentвusers.yml, смержили PR без ожидания обычного ревью (incident-режим), форсировалиansible-pullна всех 42 хостах командой из CI, а не ждали ночного таймера. - Проверили вручную, а не поверили автоматике. После прогона Ansible снова прогнали
fingerprints.shпо всем серверам — на трёх хостах, где ansible-pull не был настроен (легаси, руки не дошли), ключ пришлось стереть руками отдельно. - Оценили, что могли увидеть. Подняли историю сессий и проверили, какие файлы читались в директориях с
.envи.pgpass. Нашли два случая, когда в рамках диагностики был открыт.envодного сервиса — по-хорошему, там были действующие пароли к БД и API-ключ внешнего провайдера. Их ротировали в тот же день, не дожидаясь окончательных выводов о намерениях бывшего сотрудника — если секрет мог быть виден постороннему больше полугода, дешевле сменить его, чем гадать.
Отдельно зафиксировали инцидент письменно и уведомили юриста — формально сотрудник не имел права заходить на серверы после увольнения независимо от того, кто виноват в том, что доступ не отозвали; это отдельный юридический вопрос, который стоит закрыть заранее — до подписания NDA с любым, кто получает доступ к продакшену (эта тема разобрана в статье про оформление NDA для доступа подрядчика к серверу).
Что изменили в процессе
Разовая чистка ключей ничего не решает, если процесс останется прежним — через год история повторится с другим именем в логах. Поменяли три вещи.
Единый источник правды для доступа. Все серверы постепенно переводим на схему bastion-а: SSH-сертификаты с коротким TTL, выпускаемые через internal CA (step-ca), привязанные к статусу пользователя в Keycloak. Как только учётка отключена в IdP, новый сертификат просто не выпустится, а старый истечёт максимум через 12 часов — никакого статичного authorized_keys, который можно забыть подчистить, вообще не остаётся. Переезд не мгновенный: часть легаси-хостов ещё ждёт очереди, и там временно держим только контрольные механизмы ниже.
Автоматическая сверка доступов, а не разовый аудит под клиента. Ночной джоб раз в сутки сравнивает список активных сотрудников из HR API с содержимым authorized_keys по всему инвентарю и шлёт алерт в канал безопасности при любом расхождении — увольнение сотрудника теперь автоматически проверяется на всех серверах, а не только на тех, что кто-то держит в голове как критичные. О том, как выстроить такую сверку для VPN-доступов, у нас есть отдельный материал — аудит доступов VPN: кто и когда подключался.
Офбординг привязан к репозиторию, а не к памяти человека. В чек-лист увольнения добавили обязательный пункт «убрать пользователя из ops-infra/group_vars и смержить PR» с конкретным ответственным и дедлайном в один час с момента заявки HR. Дальше пошли ещё на шаг: HR-система теперь дёргает webhook, который сам открывает PR на удаление строки при статусе «уволен» — человеку остаётся только подтвердить и смержить, а не вспоминать про репозиторий, о существовании которого он может не знать. Общий подход к тому, как раздавать (и вовремя отзывать) доступ команде без раздачи root каждому, — в статье как раздать доступ команде без выдачи root.
Если у вас серверы разбросаны по нескольким площадкам — свои дата-центры, аренда в России и за рубежом, оплата картой или криптой у разных провайдеров, — чек-лист офбординга должен закрывать каждую площадку отдельно и явно. Ключ, забытый на «дальнем» хостинге, которым занимается не основная команда, теряется из виду быстрее всего — именно так и оставшиеся 36 из 42 серверов в нашем случае не попали в ручную проверку.
Отдельно завели практику ротации всех разделяемых секретов — паролей к БД, API-ключей, что могли «засветиться» при любом расследовании доступа, — по фиксированному расписанию, а не только по факту инцидента; практику и инструменты для этого разбирали в статье ротация секретов и ключей: практика.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстро проверить прямо сейчас, нет ли у бывших сотрудников доступа к вашим серверам?
Соберите fingerprints всех ключей из authorized_keys по инвентарю (пример скрипта выше) и сверьте с текущим списком сотрудников. Если инвентаря нет и серверов немного, можно пройтись руками: for host in $(cat hosts.txt); do ssh $host "cat ~/.ssh/authorized_keys"; done и визуально сверить комментарии в ключах с HR-списком.
Обязательно ли переходить на SSH-сертификаты, если серверов пять-десять?
Не обязательно — для небольшой инфраструктуры может хватить дисциплины: единый список ключей в одном месте (например, в том же Ansible-репозитории) и регулярный, а не разовый аудит раз в квартал. Сертификаты и внешний IdP оправданы, когда серверов много, состав команды меняется чаще пары раз в год или нужно формально подтверждать процесс контроля доступа для аудита/клиента.
Что делать, если конфигурационный менеджер (Ansible, Puppet, Chef) «восстанавливает» вручную удалённые вещи?
Это самая частая причина «зомби-доступа»: ручное удаление лечит симптом, а не причину. Любое изменение доступа нужно вносить в источник правды, который читает автоматика (git-репозиторий, HR API, Vault), а не только на самом сервере — иначе следующий прогон плейбука откатит изменение обратно.
Как узнать, кто и когда заходил по SSH, если логи хранятся только на самих серверах и недолго?
Настройте централизованный сбор auth-логов (rsyslog/Vector в Loki или ELK) с хранением от полугода и auditd для детальной трассировки команд внутри сессии — без этого расследование сводится к тому, что успело сохраниться локально до ротации логов. Подробная настройка — в статье про auditd и аудит логов сервера.
Стоит ли увольнять сотрудника «жёстко» день в день, если знаешь, что доступ технически не успеют отозвать везде?
Дата увольнения и дата фактического отзыва доступа — разные вещи, и путать их опасно: правильный порядок — сначала синхронно отзывать все виды доступа (IdP, VPN, git, серверы) в момент объявления об увольнении, а не полагаться на то, что «он и так порядочный человек». Наш кейс показывает, что даже без злого умысла восемь месяцев незакрытого доступа — это чистый риск, который просто никто не видел.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →