Содержание урока · 10 разделов
- Секреты в логах: пути утечки
- Маскирование логов go: ReplaceAttr по ключам
- LogValuer: маска на уровне значения
- Что нельзя логировать: ПДн и платёжные данные
- Тип данных и стратегия в записях
- Спорное: маскировать в приложении или на коллекторе
- Чек-лист по секретам
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Логи — самое долгоживущее место, куда попадают данные сервиса: записи хранятся месяцами, уезжают в бэкапы, реплицируются в конвейер сбора и открыты более широкому кругу людей, чем база данных. Пароль в переменной кода заметят на ревью, а пароль в записи лога может лежать годами, пока кто-то не захочет разобрать старый инцидент. В этом уроке составим карту путей утечки — от логирования заголовков целиком до 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: маска по чувствительным ключам
GOvar 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, функция не побывает — маскирование по ключам работает на атрибутах, а не на полях произвольных значений.
Кейс: сервис логировал входящий запрос целиком с заголовками — отладка была быстрее. Bearer-токены пользователей осели в Loki, где доступ шире, чем к базе данных: читать записи может половина дежурных. Через месяц половину записи скопировали в публичный issue вместе с реальным токеном — воспроизведение бага со скриншотом. Отзыв токена занял вечер, аудит доступа к хранилищу — неделю. Фикс: заголовки логируются именами и длинами, значения не логируются в принципе; токен передаётся типом с LogValue, а ReplaceAttr ловит остатки по ключам; тест логирует фикстуру с настоящим по виду токеном и проверяет маску. Утечка через лог не фиксируется постфактум — она предотвращается на границе записи.
LogValuer: маска на уровне значения#
Вторая механика сильнее первой: маска привязывается не к ключу, а к типу. Тип Token объявляет метод LogValue, и Handler вызывает его при выводе — наружу идёт маска, какой бы ключ ни придумал вызывающий. Утечка становится невозможной по построению: передать Token в лог значит замаскировать его, и никакое переименование поля, ни один новый вызов не обходят механизм. Механика разбиралась в девятом уроке как отложенное вычисление — здесь у неё вторая профессия, маскирование. Правило внедрения: типы секретов объявляются в пакете домена — Token, Password, Secret — и все функции принимают и возвращают их вместо строк; тогда компилятор гарантирует, что секрет в записи окажется только маской. ReplaceAttr остаётся второй линией: чужой код может передать секрет строкой под незнакомым ключом, и здесь его поймает список чувствительных ключей на уровне обработчика.
Тип Token с LogValue
GOtype 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 закрывают значения на границе записи, — а коллекторное правило остаётся второй линией на случай чужого кода, который пишет без вашего обработчика. Ошибка обратного рода — полагаться только на коллектор: маскирование на границе записи стоит дешевле любого расследования уже случившейся утечки.
Записи с персональными данными — это их обработка в смысле 152-ФЗ: у оператора появляется цель обработки, состав данных, срок хранения и обязанность защиты — доступ, шифрование хранилища. Минимизация состава и обезличивание — маскирование и идентификаторы вместо значений — снижают и риски, и объём обязанностей. При утечке записей с ПДн наступает порядок уведомлений, поэтому цена одной записи с реальным телефоном измеряется не только репутацией. Честная оговорка: юридическая оценка схемы логирования — задача юриста или специалиста по ИБ, а не этой главы.
Чек-лист по секретам#
- Логируйте идентификаторы, а не значения: user_id вместо email, сессия вместо токена.
- Типы Token, Password, Secret с LogValue: утечка невозможна по построению.
- ReplaceAttr — вторая линия по ключам password, token, secret, authorization.
- Заголовки запроса целиком не логируются: имена и длины вместо значений.
- Карты — только последние 4 цифры; CVV и ПИН в записях не существуют.
- Тест логирует фикстуру с секретами и проверяет маску — механика двадцатого урока.
Пакета log/slog в песочнице нет — там работают fmt, strings, strconv, math, sort, errors и time. Механику маскирования она разминает честно: соберите функцию mask(s string) string на срезах и конкатенации — первые четыре символа, многоточие, последние четыре; короткая строка заменяется целиком. Прогоните маску через strconv.Quote и убедитесь, что она не ломает формат строки; затем соберите форматтер записи на fmt.Printf, который маскирует значения по карте ключей — это модель ReplaceAttr. Полная механика с LogValue и HandlerOptions запускается локально через go run.
Проверка: 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.
Тип-маска по строкам
Расставьте по порядку: объявление типа, метод с маской, проверка контракта, логгер, запись, комментарий о выводе.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: секреты и маскирование
Секреты закрыты в два слоя: типы с LogValue делают утечку невозможной по построению, ReplaceAttr ловит остатки по ключам, а минимизация уменьшает само количество чувствительных значений в записях. Дальше по курсу: девятый урок про LogValuer разбирает механику отложенных значений, четвёртый — конструкторы атрибутов, восемнадцатый — почему Debug в проде опасен ещё и секретами, семнадцатый — что из полей дойдёт до конвейера, а двадцатый — как проверить маску тестом. Устройство токенов, которые мы маскируем, — в курсе по JWT. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Секреты попадают в записи через объекты целиком, ошибки с URL и SQL, забытые отладочные записи и чужой код.
- Удалить запись из хранилища и бэкапов задним числом нельзя: защита работает до записи.
- ReplaceAttr по чувствительным ключам — вторая линия; поля структур он не перебирает.
- Тип Token с LogValue маскирует значение на уровне типа: утечка невозможна по построению.
- Минимизация: идентификаторы вместо значений; карты — 4 цифры, CVV и ПИН не существуют в записях.
- Записи с ПДн — обработка по 152-ФЗ: цель, состав, срок хранения, защита и порядок уведомлений об утечке.
Домашнее задание
Соберите тип Secret с LogValue и маской из урока, добавьте ReplaceAttr со списком чувствительных ключей и напишите запись с реальным по виду токеном в двух видах: типом и сырой строкой. Затем прогоните через записи фикстуру своего домена — объект запроса с заголовками, конфиг с паролем — и выпишите, что просочилось. Для каждого просочившегося значения решите: идентификатор, маска или запрет.
Личные заметкиПусто
Вопросы и ответы по уроку
Маскировать или вообще не логировать секрет?
Не логировать там, где значение ничего не даёт: идентификатор сессии отвечает на вопрос дежурного без секрета. Маска нужна, когда форма значения важна для отладки — первые символы JWT показывают, тот ли алгоритм, — но и она не должна открывать половину секрета.
Что делать с секретами, которые уже уехали в хранилище?
Отозвать и перевыпустить: токены инвалидируются на стороне сервиса, пароли меняются. Удалить записи из хранилища и бэкапов в большинстве систем нельзя — считается, что секрет скомпрометирован. Дальше — маскирование у источника и тест на фикстуре, чтобы повторение стало невозможным.
Достаточно ли ReplaceAttr, чтобы считаться обезличиванием?
Механика — нет, результат — да: если в записях не осталось значений, позволяющих определить человека, а остались идентификаторы и маски, состав данных существенно снижается. Оценка обезличивания — юридический вопрос, и её проводят вместе со специалистом по ИБ на фактических записях, а не по списку правил.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Тестирование логов: capture и утверждения
- Логи и метрики: связка с Prometheus
- Логи и трейсинг: trace_id в каждом сообщении
- Антипаттерны: как логи теряют смысл
- Уровни по окружению: debug в dev, info в прод
- Логи для сбора: поля для Loki и ELK
- Куда пишут логи: stdout, файл и ротация
- Сквозные поля: request ID через слои
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY