vegman.dev
Все статьи

Секреты в пайплайне: почему токен не должен попадать в код

2026-06-11кейс

Скрипт публикации у меня в терминале отработал десять раз подряд. Я поставила его в планировщик - и он начал падать с «access denied». Тот же код, та же машина, тот же пользователь. Руками - работает, по расписанию - нет. Разбор занял неприятно много времени, и в итоге выяснилось: дело было не в правах на сервис, а в том, какой именно ключ добрался до кода в момент запуска.

Эта заметка - про секреты в автоматизации: токены, ключи доступа, пароли, которые скрипт предъявляет внешним сервисам. Без обзора хранилищ и моделей угроз - только несколько правил, каждое из которых родилось у меня из конкретной ошибки, и простой способ проверить, что вы их реально соблюдаете.

flowchart LR
  S[Секрет] --> A[Аргумент команды]
  S --> B[Токен в строке адреса в коде]
  S --> C[Ключ в адресе запроса]
  A --> A1[Список процессов и история оболочки]
  B --> B1[Исходники и резервные копии]
  C --> C1[Текст ошибки в журнале]
  A1 --> F1[Чтение из файла окружения]
  B1 --> F2[Штатное хранение учетных данных]
  C1 --> F3[Маскирование перед записью]
Три пути, по которым секрет уходит из-под контроля, и что его там ловит

Три места, где секрет утекает незаметно

Утечка редко выглядит как утечка. Никто не выкладывает ключ специально - он просто оказывается в месте, которое вы не считали хранилищем.

  • Аргументы команды.

Самый «естественный» способ передать токен скрипту - вписать его прямо в строку запуска. Проблема в том, что аргументы видны в списке процессов, а после - остаются в истории оболочки. История переживает перезагрузку и попадает в бэкапы домашнего каталога.

  • Код.

Классика: чтобы пушить в репозиторий, токен подставляют внутрь URL - и он внезапно становится частью исходников. Дальше он едет во все копии проекта, архивы, бэкапы и в переписку «посмотри, что не так». Секрет, попавший в код, перестает быть вашим - вы больше не контролируете список мест, где он лежит.

  • Сообщения об ошибках.

Самое обидное: вы вроде сделали все правильно, а утечка случилась все равно. Некоторые сервисы принимают ключ прямо в адресе запроса. Пока все работает, вы этого не замечаете. На сбое клиентская библиотека формирует текст ошибки, вставляет в него URL запроса целиком - и ваш обработчик аккуратно пишет это в лог. А логи живут долго, читаются разными людьми и часто уезжают в системы централизованного сбора.

Правило 1: секрет читается там, где используется

Секрет не передается аргументом и не хранится в исходнике. Он лежит в файле окружения рядом со скриптом (обычный текстовый файл «переменная = значение»). Скрипт читает значение в момент использования, а не таскает его через половину программы.

И обязательно: права на файл - только владельцу. Файл окружения с правами «читать всем» отличается от токена в коде лишь тем, что его сложнее случайно скопировать.

Ловушка: чей ключ победил

Тут история про планировщик. Загрузчик env-файла по умолчанию был в режиме «не перезаписывать то, что уже есть в окружении». Логика понятная: окружение считается более явным указанием, чем файл.

А под планировщиком в окружении уже лежал ключ от другой интеграции - под тем же именем переменной. Мой файл честно прочитался, но значение было проигнорировано как «уже установленное». Скрипт молча предъявил чужой ключ чужому сервису и получил отказ.

Вывод, который теперь включаю по умолчанию: файл рядом со скриптом должен побеждать унаследованное окружение. Автоматизация стартует в среде, которую вы не строили и не видите, и наследует из нее все подряд. Если файл не может перезаписать переменные - вы получаете тихие ошибки, которые нельзя воспроизвести вручную.

Правило 2: штатный механизм вместо самодельного

Токен в URL для пуша в репозиторий лечится не аккуратностью, а отказом от самодеятельности. У инструмента контроля версий есть штатный механизм хранения учетных данных: он сам помнит, чем авторизоваться, и подставляет это при обращении к серверу. Скрипту нечего «собирать в строку» - значит, нечему попадать в исходники.

Обобщение: если у инструмента есть свой способ хранить креды - используйте его, даже если ваш вариант кажется на пять строк короче. Штатный механизм отделяет секрет от кода на уровне устройства инструмента. Самодельный держится только на дисциплине - а дисциплина ломается в первый же вечер «срочно починить».

import logging
import os
from pathlib import Path

from dotenv import load_dotenv

# override=True: файл рядом со скриптом важнее того,
# что унаследовано от планировщика
load_dotenv(Path(__file__).with_name(".env"), override=True)


def token() -> str:
    # читаем там, где используем, а не в глобальной константе
    return os.environ["PUBLISH_TOKEN"]


def mask(text: str) -> str:
    # замена по значению, а не по шаблону «похоже на ключ»
    return text.replace(token(), "***")


def publish(payload):
    try:
        return send(payload, api_key=token())
    except Exception as err:
        logging.error("публикация не удалась: %s", mask(str(err)))
        raise
Файл рядом со скриптом побеждает окружение, секрет читается в точке использования, ошибка маскируется перед журналом

Правило 3: маскировать в логах

Третью утечку нельзя закрыть на входе: текст ошибки приходит из чужого кода, вы не управляете тем, что туда положат. Значит, фильтр ставится на выходе: перед записью в лог прогоняем текст через замену, которая находит значение секрета и подставляет заглушку.

Важная деталь: маскировать надо по значению, а не по шаблону «похоже на ключ». Шаблон отстанет от реальности при первой смене формата токена, а замена по фактическому значению работает всегда.

Применить у себя

Что проверить на практике (короткий чек-лист)

  • Найдите места, где секрет передается в аргументах командной строки - перенесите в env-файл.
  • Поищите в исходниках сборку URL/строк, куда подставляется токен - замените на штатный механизм хранения учетных данных.
  • Проверьте права на файлы с секретами: только владелец, без группы и остальных.
  • Проверьте режим загрузчика env-файла: если не перезаписывает уже установленные переменные - включите перезапись явно.
  • Найдите все места, где ошибки пишутся в лог - поставьте маскирование по значению секрета перед записью.
  • Просмотрите историю оболочки на предмет старых команд с ключами: если уже утекло - не прятать, а перевыпускать.

Как понять, что получилось

Критерий простой и проверяемый: поиск по значению токена не должен находить его нигде, кроме env-файла. Прогоните поиск по проекту, логам и истории оболочки - вне env-файла должно быть 0 совпадений.

И отдельная проверка на «ловушку окружения»: запустите скрипт под планировщиком, предварительно положив в окружение переменную с тем же именем и заведомо неверным значением. Правильно настроенный скрипт отработает нормально - файл победил. Если снова «access denied» - значит, у вас та же тихая подмена, и вы нашли ее сами, а не через неделю в отчете о сбое.