---
title: "Секреты в логах: токены, пароли и ПДн"
description: "Секреты в логах: токены, пароли и ПДн; маскирование логов go через ReplaceAttr и LogValuer, 152-ФЗ и чек-лист перед сбором на прод и в бэкапы."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/19-sekrety-v-logah/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Логирование в Go: структурные логи на slog», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/19-sekrety-v-logah/*

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

Логи — самое долгоживущее место, куда попадают данные сервиса: записи хранятся месяцами, уезжают в бэкапы, реплицируются в конвейер сбора и открыты более широкому кругу людей, чем база данных. Пароль в переменной кода заметят на ревью, а пароль в записи лога может лежать годами, пока кто-то не захочет разобрать старый инцидент. В этом уроке составим карту путей утечки — от логирования заголовков целиком до 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, функция не побывает — маскирование по ключам работает на атрибутах, а не на полях произвольных значений.

> **Токен уехал в Loki и публичный issue.** Кейс: сервис логировал входящий запрос целиком с заголовками — отладка была быстрее. Bearer-токены пользователей осели в Loki, где доступ шире, чем к базе данных: читать записи может половина дежурных. Через месяц половину записи скопировали в публичный issue вместе с реальным токеном — воспроизведение бага со скриншотом. Отзыв токена занял вечер, аудит доступа к хранилищу — неделю. Фикс: заголовки логируются именами и длинами, значения не логируются в принципе; токен передаётся типом с LogValue, а ReplaceAttr ловит остатки по ключам; тест логирует фикстуру с настоящим по виду токеном и проверяет маску. Утечка через лог не фиксируется постфактум — она предотвращается на границе записи.

### 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 закрывают значения на границе записи, — а коллекторное правило остаётся второй линией на случай чужого кода, который пишет без вашего обработчика. Ошибка обратного рода — полагаться только на коллектор: маскирование на границе записи стоит дешевле любого расследования уже случившейся утечки.

> **152-ФЗ: логи как обработка персональных данных.** Записи с персональными данными — это их обработка в смысле 152-ФЗ: у оператора появляется цель обработки, состав данных, срок хранения и обязанность защиты — доступ, шифрование хранилища. Минимизация состава и обезличивание — маскирование и идентификаторы вместо значений — снижают и риски, и объём обязанностей. При утечке записей с ПДн наступает порядок уведомлений, поэтому цена одной записи с реальным телефоном измеряется не только репутацией. Честная оговорка: юридическая оценка схемы логирования — задача юриста или специалиста по ИБ, а не этой главы.

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

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

> **Песочница: маска строки на strings и срезах.** Пакета 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?

- ✅ Нет: ReplaceAttr видит атрибут request целиком, поля структуры не перебираются
- Да: ReplaceAttr рекурсивно проходит все поля любой структуры
- Да, если перечислить группы в аргументе функции
- Нет: структуры вообще нельзя передавать атрибутами

Перебор идёт по атрибутам записи и внутри групп-атрибутов; поля произвольных структур Handler сериализует на вывод без обращения к ReplaceAttr. Секрет в структуре защищают типом с LogValue: тогда маска срабатывает на уровне значения.

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

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

- ✅ До коллектора записи проходят сеть, диск и бэкапы: утечка на этом пути конфигом не отменяется
- Маскирование на коллекторе ломает JSON: записи перестают парситься
- Коллектор видит только stdout: записи, уехавшие в файл, он не читает
- ReplaceAttr — единственный способ маскирования: коллекторных механизмов не существует

Запись, ушедшая из процесса с реальным токеном, уже скопирована везде, где её ждёт конвейер, — вплоть до бэкапов хранилища. Маскирование у источника делает значение безопасным до записи; коллекторное правило остаётся второй линией для чужого кода.

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

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

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

**Что выведет программа?**

- ✅ *** eyJh...NiJ9
- abc eyJhbGciOiJIUzI1NiJ9
- *** eyJhbGciOiJIUzI1NiJ9
- a...c eyJh...NiJ9

Короткая строка заменяется целиком: три символа не стоит показывать даже наполовину. Длинный токен теряет середину и остаётся узнаваемым для отладки — первые и последние четыре символа. Половина короткого секрета — маска, которая хуже отсутствия значения.

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

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

```go
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"))
```

Подсказка: Контракт LogValuer реализует метод LogValue, строковое значение строит slog.StringValue, запись уровня Info делает метод Info.

Правильные ответы в пропусках: `LogValue`, `StringValue`, `Info`

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

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

Правильный порядок строк:

1. `type Token string`
2. `func (t Token) LogValue() slog.Value { return slog.StringValue("***") }`
3. `var _ slog.LogValuer = Token("")`
4. `logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))`
5. `logger.Info("клиент авторизован", "token", Token("eyJhbGciOiJIUzI1NiJ9"))`
6. `// в вывод уйдёт маска, а не значение токена`

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

- **ReplaceAttr по ключам** — Вторая линия: перезапись атрибутов с ключами password, token, secret. Поля структур не перебираются — только атрибуты и группы.
- **LogValue у типа** — Маска привязана к типу: Token с LogValue не может утечь, каким бы ни был ключ. Компилятор держит контракт.
- **минимизация** — Идентификаторы вместо значений: user_id вместо email, сессия вместо токена. Отвечает на вопрос дежурного без раскрытия данных.
- **заголовки запроса** — Authorization и Cookie целиком не логируются: имя и длина заголовка полезнее значения и безопаснее.
- **платёжные данные** — Карты — только последние 4 цифры; CVV и ПИН не попадают в записи ни в каком виде.
- **152-ФЗ и логи** — Записи с ПДн — обработка персональных данных: цель, состав, срок хранения и защита. Обезличивание снижает риски.

Секреты закрыты в два слоя: типы с LogValue делают утечку невозможной по построению, ReplaceAttr ловит остатки по ключам, а минимизация уменьшает само количество чувствительных значений в записях. Дальше по курсу: [девятый урок про LogValuer](/courses/slog/lessons/09-logvalue/) разбирает механику отложенных значений, [четвёртый](/courses/slog/lessons/04-klyuchi-atributy/) — конструкторы атрибутов, [восемнадцатый](/courses/slog/lessons/18-urovni-po-env/) — почему Debug в проде опасен ещё и секретами, [семнадцатый](/courses/slog/lessons/17-logi-dlya-sbora/) — что из полей дойдёт до конвейера, а [двадцатый](/courses/slog/lessons/20-testy-logov/) — как проверить маску тестом. Устройство токенов, которые мы маскируем, — в [курсе по JWT](/courses/jwt-go/). Карта курса — на [странице курса](/courses/slog/).

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

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

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

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

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

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

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

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

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

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

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