Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +20 XP

Секреты в логах: токены, пароли и ПДн

Секреты в логах — самая дорогая ошибка логирования: одна запись с токеном переживает десятки релизов, копируется в бэкапы и реплики конвейера, а удалить её из хранилища сложнее, чем исправить код. Маскирование логов go строится в два слоя: типы с LogValue, которые не могут утечь по построению, и ReplaceAttr как вторая линия по ключам. Разберём пути утечки, обе механики маскирования и требования к персональным данным в записях.

Содержание урока · 10 разделов

Логи — самое долгоживущее место, куда попадают данные сервиса: записи хранятся месяцами, уезжают в бэкапы, реплицируются в конвейер сбора и открыты более широкому кругу людей, чем база данных. Пароль в переменной кода заметят на ревью, а пароль в записи лога может лежать годами, пока кто-то не захочет разобрать старый инцидент. В этом уроке составим карту путей утечки — от логирования заголовков целиком до SQL-ошибок с данными, — соберём маскирование двумя механиками slog и честно поговорим про персональные данные: что вообще нельзя записывать, что маскировать и как это согласуется с требованиями закона о персональных данных.

Секреты в логах: пути утечки#

Секреты попадают в записи четырьмя типовыми дорогами. Первая — логирование объекта целиком: slog.Any("request", req) вместе с заголовками Authorization и Cookie, slog.Any("config", cfg) с паролем базы — удобно для отладки и катастрофично по последствиям, потому что в объекте есть всё, включая то, чего вы в нём не ожидали. Вторая — ошибки: err от SQL-драйвера с текстом запроса и значениями, ошибка HTTP-клиента с URL, в котором токен живёт в query-параметре. Третья — отладочные записи на горячем пути, которые писали во время инцидента и забыли убрать: секретные значения уходят в прод и живут там до конца срока хранения. Четвёртая — чужой код: библиотека с собственным логированием, которая печатает параметры своих вызовов. Особенность логов — цена удаления: запись скопирована в хранилище, архив, бэкап; изменить её задним числом в большинстве систем нельзя, поэтому защита работает до записи, а не после.

Маскирование логов go: ReplaceAttr по ключам#

Первая механика — HandlerOptions.ReplaceAttr: функция вызывается для каждого атрибута записи перед выводом, и на совпадении ключа можно заменить значение маской. Это вторая линия обороны: она ловит то, что просочилось мимо сознательного кода, — забытое отладочное поле, чужой ключ с секретом. Список чувствительных ключей держат в одном месте: password, token, secret, authorization, cookie — и маскируют значение функцией, которая показывает первые символы и длину. Честная граница механики: ReplaceAttr видит атрибуты записи и атрибуты внутри групп, но не поля произвольных структур — если секрет запечён в структуру, переданную slog.Any, перебора полей не будет, и на выход уедет всё. Поэтому схема работает так: секрет передаётся отдельным атрибутом со знакомым ключом — его маскирует ReplaceAttr, — а объекты целиком в записи не попадают. Замена не удаляет факт записи: ключ остаётся в выводе, и по нему видно, что поле было, — это удобно для отладки и честно для ревью.

ReplaceAttr: маска по чувствительным ключам

GO
var sensitive = map[string]bool{
        "password": true, "token": true, "secret": true, "authorization": true,
}

func replaceSecret(groups []string, a slog.Attr) slog.Attr {
        if sensitive[a.Key] {
                if s, ok := a.Value.Any().(string); ok {
                        a.Value = slog.StringValue(mask(s))
                }
        }
        return a
}

func mask(s string) string {
        if len(s) <= 8 {
                return "***"
        }
        return s[:4] + "..." + s[len(s)-4:]
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: replaceSecret}))
logger.Info("клиент авторизован", "user", "anna", "token", "eyJhbGciOiJIUzI1NiJ9")

Атрибут token пройдёт через replaceSecret, совпадёт с ключом и уйдёт в вывод маской eyJh...NiJ9 — формат записи сохранится, а значение исчезнет. ReplaceAttr срабатывает и на атрибуты внутри групп, поэтому поле http.token из седьмого урока тоже поймается. Внутри структур, переданных slog.Any, функция не побывает — маскирование по ключам работает на атрибутах, а не на полях произвольных значений.

LogValuer: маска на уровне значения#

Вторая механика сильнее первой: маска привязывается не к ключу, а к типу. Тип Token объявляет метод LogValue, и Handler вызывает его при выводе — наружу идёт маска, какой бы ключ ни придумал вызывающий. Утечка становится невозможной по построению: передать Token в лог значит замаскировать его, и никакое переименование поля, ни один новый вызов не обходят механизм. Механика разбиралась в девятом уроке как отложенное вычисление — здесь у неё вторая профессия, маскирование. Правило внедрения: типы секретов объявляются в пакете домена — Token, Password, Secret — и все функции принимают и возвращают их вместо строк; тогда компилятор гарантирует, что секрет в записи окажется только маской. ReplaceAttr остаётся второй линией: чужой код может передать секрет строкой под незнакомым ключом, и здесь его поймает список чувствительных ключей на уровне обработчика.

Тип Token с LogValue

GO
type Token string

func (t Token) LogValue() slog.Value {
        s := string(t)
        if len(s) <= 8 {
                return slog.StringValue("***")
        }
        return slog.StringValue(s[:4] + "..." + s[len(s)-4:])
}

var _ slog.LogValuer = Token("")

logger.Info("клиент авторизован", "token", Token("eyJhbGciOiJIUzI1NiJ9"))

Handler встречает значение типа Token, вызывает LogValue и печатает маску eyJh...NiJ9 вместо строки. Проверка var _ slog.LogValuer = Token("") держит контракт в компиляции: переименование метода сломает сборку, а не безопасность. Пустой и короткий токены заменяются целиком — маска, которая открывает половину секрета, хуже отсутствия значения.

Что нельзя логировать: ПДн и платёжные данные#

Секреты доступа — половина истории; вторая — персональные данные. Минимальная стратегия — минимизация: логировать идентификаторы, а не значения. user_id вместо email и телефона, идентификатор заказа вместо адреса доставки, сессия вместо токена. Идентификатор отвечает на вопрос дежурного «чей это запрос», не раскрывая данных, и с точки зрения закона не создаёт лишней обработки. Заголовки запроса целиком не логируются: в них живут токены, куки, иногда номера телефонов из форм. Платёжные данные жёстче всего: полный номер карты в записях хранить нельзя, в логах допустима только маска из последних четырёх цифр, а CVV и ПИН не должны существовать в записях ни в каком виде — системы хранения карт требуют отдельного режима и аудита, и логи в этот режим не входят. Медицинские и паспортные данные в записях — та же история, что и карты: идентификаторы вместо значений. Практика простая: перед выходом на прод прогнать через записи фикстуры с самыми чувствительными данными вашего домена и проверить, что от них остаётся маска или идентификатор.

Тип данных и стратегия в записях#

ДанныеСтратегияЗамечание
Токены и паролитип с LogValue плюс ReplaceAttr по ключаммаска первых символов или замена целиком
Authorization и Cookieне логировать значенияимя и длина заголовка полезнее значения
Email и телефонмаска или идентификаторuser_id отвечает на вопрос без раскрытия данных
Номер картытолько последние 4 цифрыполный номер в записях хранить нельзя
CVV и ПИНне существуют в записяхбез вариантов: их нет ни масками, ни хешами
Адреса и паспортные данныеидентификаторы справочниковзначения остаются в базе, не в логах

Спорное: маскировать в приложении или на коллекторе#

Мнение, которое стоит оспорить на ретро: маскирование — задача конвейера, один конфиг на коллекторе закроет все сервисы. За: единое место, не нужно трогать код, правило меняется в одном конфиге. Против: до коллектора записи прошли сеть, файл на диске, бэкап — утечка на этом пути конфигом не отменяется; конфиги маскирования отстают от кода, потому что новый секрет появляется в сервисе раньше, чем правило в конвейере; а тестировать маску в конфиге сложнее, чем в Go-тесте из двадцатого урока. Компромисс, который прижился: маскирование у источника обязательно — типы с LogValue и ReplaceAttr закрывают значения на границе записи, — а коллекторное правило остаётся второй линией на случай чужого кода, который пишет без вашего обработчика. Ошибка обратного рода — полагаться только на коллектор: маскирование на границе записи стоит дешевле любого расследования уже случившейся утечки.

Чек-лист по секретам#

  • Логируйте идентификаторы, а не значения: user_id вместо email, сессия вместо токена.
  • Типы Token, Password, Secret с LogValue: утечка невозможна по построению.
  • ReplaceAttr — вторая линия по ключам password, token, secret, authorization.
  • Заголовки запроса целиком не логируются: имена и длины вместо значений.
  • Карты — только последние 4 цифры; CVV и ПИН в записях не существуют.
  • Тест логирует фикстуру с секретами и проверяет маску — механика двадцатого урока.

Проверка: ReplaceAttr и структуры

В записи встречается slog.Any("request", req), где req — структура с полем Password. Замаскирует ли ReplaceAttr по ключу password значение внутри req?

Проверка: почему маскирование у источника

Коллектор умеет маскировать значения по конфигу. Почему маскирование логов go всё равно делают в приложении?

Что напечатают вызовы маски?

Прочитайте код и выберите, что он выведет:

func mask(s string) string {
        if len(s) <= 8 {
                return "***"
        }
        return s[:4] + "..." + s[len(s)-4:]
}

fmt.Println(mask("abc"), mask("eyJhbGciOiJIUzI1NiJ9"))

Соберите тип-маску

Допишите: имя метода LogValuer, конструктор строкового значения и запись уровня Info.

type Token string func (t Token) () slog.Value { s := string(t) if len(s) <= 8 { return slog.("***") } return slog.StringValue(s[:4] + "..." + s[len(s)-4:]) } logger.("клиент авторизован", "token", Token("eyJhbGciOiJIUzI1NiJ9"))

Тип-маска по строкам

Расставьте по порядку: объявление типа, метод с маской, проверка контракта, логгер, запись, комментарий о выводе.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: секреты и маскирование

Термин

ReplaceAttr по ключам

нажмите, чтобы перевернуть
Определение

Вторая линия: перезапись атрибутов с ключами password, token, secret. Поля структур не перебираются — только атрибуты и группы.

1 / 6

Секреты закрыты в два слоя: типы с LogValue делают утечку невозможной по построению, ReplaceAttr ловит остатки по ключам, а минимизация уменьшает само количество чувствительных значений в записях. Дальше по курсу: девятый урок про LogValuer разбирает механику отложенных значений, четвёртый — конструкторы атрибутов, восемнадцатый — почему Debug в проде опасен ещё и секретами, семнадцатый — что из полей дойдёт до конвейера, а двадцатый — как проверить маску тестом. Устройство токенов, которые мы маскируем, — в курсе по JWT. Карта курса — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Секреты попадают в записи через объекты целиком, ошибки с URL и SQL, забытые отладочные записи и чужой код.
  • Удалить запись из хранилища и бэкапов задним числом нельзя: защита работает до записи.
  • ReplaceAttr по чувствительным ключам — вторая линия; поля структур он не перебирает.
  • Тип Token с LogValue маскирует значение на уровне типа: утечка невозможна по построению.
  • Минимизация: идентификаторы вместо значений; карты — 4 цифры, CVV и ПИН не существуют в записях.
  • Записи с ПДн — обработка по 152-ФЗ: цель, состав, срок хранения, защита и порядок уведомлений об утечке.

Домашнее задание

Соберите тип Secret с LogValue и маской из урока, добавьте ReplaceAttr со списком чувствительных ключей и напишите запись с реальным по виду токеном в двух видах: типом и сырой строкой. Затем прогоните через записи фикстуру своего домена — объект запроса с заголовками, конфиг с паролем — и выпишите, что просочилось. Для каждого просочившегося значения решите: идентификатор, маска или запрет.

Личные заметкиПусто
Хранятся только в вашем браузере (Slog · Секреты в логах: токены, пароли и ПДн)

Вопросы и ответы по уроку

Маскировать или вообще не логировать секрет?

Не логировать там, где значение ничего не даёт: идентификатор сессии отвечает на вопрос дежурного без секрета. Маска нужна, когда форма значения важна для отладки — первые символы JWT показывают, тот ли алгоритм, — но и она не должна открывать половину секрета.

Что делать с секретами, которые уже уехали в хранилище?

Отозвать и перевыпустить: токены инвалидируются на стороне сервиса, пароли меняются. Удалить записи из хранилища и бэкапов в большинстве систем нельзя — считается, что секрет скомпрометирован. Дальше — маскирование у источника и тест на фикстуре, чтобы повторение стало невозможным.

Достаточно ли ReplaceAttr, чтобы считаться обезличиванием?

Механика — нет, результат — да: если в записях не осталось значений, позволяющих определить человека, а остались идентификаторы и маски, состав данных существенно снижается. Оценка обезличивания — юридический вопрос, и её проводят вместе со специалистом по ИБ на фактических записях, а не по списку правил.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

pgx25 уроков~12 чпрактика25 уроков уже доступны

PostgreSQL в Go: драйвер pgx с нуля до продакшена

Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.

  • Подключаться к PostgreSQL через pgx и pgxpool
  • Сканировать строки в структуры и справляться с NULL
  • Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
  • Проводить транзакции и массовые вставки через Batch и COPY