Бывает так: разработчик добавляет логирование запроса и пишет туда email пользователя — чтобы удобнее было искать в логах. Или кладёт пароль базы данных прямо в файл конфигурации. Или в Kafka-событие добавляет телефон клиента, «чтобы уведомлению не нужно было делать лишний запрос».
Каждый из этих случаев — утечка. Email в логах попадает в системы мониторинга, Kafka-событие читают все подписанные сервисы, а пароль в git-истории остаётся там навсегда — даже после удаления коммита.
Разберём, где именно персональные данные и секреты вытекают в Go-приложении и как этого не допустить.
Что такое PII и почему это важно
PII (Personally Identifiable Information) — данные, по которым можно идентифицировать конкретного человека. Это email, номер телефона, ФИО, адрес, паспортные данные, IP-адрес.
Регуляторы (GDPR, 152-ФЗ и другие) требуют защищать эти данные: контролировать, кто к ним имеет доступ, где они хранятся и кому передаются. Утечка через лог, через ответ API или через Kafka — это инцидент, который может обернуться штрафом и потерей доверия пользователей.
В Go-приложении персональные данные чаще всего утекают в трёх местах:
slog-атрибуты — логи идут в системы сбора, которые хранят всё долго;- строки ошибок (
error.Error()) — ошибки попадают в логи и могут уйти в ответ API; - Kafka-события — широковещательный канал, все consumer-группы получат payload.
Персональные данные не в slog-атрибутах
Самая частая ошибка — передать PII прямо в атрибут лога:
// ПЛОХО — email оказывается в логах
slog.InfoContext(ctx, "order created",
slog.String("customer_email", cmd.CustomerEmail),
slog.String("order_id", order.ID),
)
// ХОРОШО — только идентификатор
slog.InfoContext(ctx, "order created",
slog.String("customer_id", order.CustomerID),
slog.String("order_id", order.ID),
)
Это правило работает на всех уровнях: Debug, Info, Warn, Error. Даже slog.Debug может попасть в централизованную систему логирования, которую просматривают несколько человек.
Если email всё же нужен для диагностики, его можно замаскировать:
// core/pii/mask.go
package pii
import "strings"
func MaskEmail(email string) string {
at := strings.IndexByte(email, '@')
if at < 1 {
return "***"
}
return string(email[0]) + "***@" + email[at+1:]
}
func MaskPhone(phone string) string {
if len(phone) < 4 {
return "***"
}
return "***" + phone[len(phone)-4:]
}
slog.DebugContext(ctx, "email verification sent",
slog.String("customer_id", customer.ID),
slog.String("email_mask", pii.MaskEmail(customer.Email)), // u***@example.com
)
Функции маскировки кладут в core/pii/ — этот пакет доступен всем слоям без циклических импортов.
Опасность slog.Any с агрегатом
Если передать структуру с PII-полями через slog.Any, slog вызовет fmt.Sprintf("%+v", c) — и все поля окажутся в логе. Чтобы этого не случилось, реализуйте интерфейс slog.LogValuer:
type Customer struct {
ID string
Email string
Phone string
FullName string
}
func (c Customer) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", c.ID),
)
}
Теперь slog.Any("customer", c) выведет только id.
Персональные данные не в строках ошибок
Строка ошибки (error.Error()) — это то, что попадает в лог, передаётся вверх по стеку вызовов и может оказаться в ответе API. Если туда попадёт email или телефон, утечка будет сложно отслеживаемой.
// ПЛОХО — PII в строке ошибки
type CustomerNotFoundError struct {
Email string
}
func (e *CustomerNotFoundError) Error() string {
return fmt.Sprintf("customer not found: email=%s", e.Email) // утечка
}
// ХОРОШО — только идентификатор
type CustomerNotFoundError struct {
CustomerID string
}
func (e *CustomerNotFoundError) Error() string {
return fmt.Sprintf("customer not found: id=%s", e.CustomerID)
}
Структура ошибки хранит только id агрегата — не email, не телефон, не ФИО. Идентификаторы заказов и статусы — не PII, их можно включать:
type OrderNotCancellableError struct {
OrderID string
Status string
}
func (e *OrderNotCancellableError) Error() string {
return fmt.Sprintf("order %s cannot be cancelled in status %s", e.OrderID, e.Status)
}
Безопасный рендеринг ошибок в HTTP-ответе
Ещё одна точка утечки — ответ API. Если написать detail: err.Error(), внутренние детали окажутся у клиента: сообщения от базы данных, стек вызовов, имена внутренних сервисов.
Правильный подход — разделить ошибку на «внутреннюю» (для логов) и «клиентскую» (заранее написанный текст):
// adapters/in/http/httperr/render.go
package httperr
type problemDetail struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Code string `json:"code,omitempty"`
}
func toProblem(err error) problemDetail {
var domErr *apperr.DomainError
if errors.As(err, &domErr) {
return problemDetail{
Type: "urn:domain:" + domErr.Code,
Title: "Domain rule violated",
Status: http.StatusUnprocessableEntity,
Detail: domErr.UserMessage, // заранее написанный текст, не err.Error()
Code: domErr.Code,
}
}
// технические ошибки — только общая фраза
return problemDetail{
Type: "urn:internal",
Title: "Internal server error",
Status: http.StatusInternalServerError,
Detail: "an unexpected error occurred",
}
}
UserMessage — поле, которое разработчик пишет при объявлении ошибки домена:
// core/apperr/domain.go
type DomainError struct {
Code string
UserMessage string
}
func (e *DomainError) Error() string { return "domain: " + e.Code }
// core/order/errors.go
var ErrOrderNotFound = &apperr.DomainError{
Code: "ORDER_NOT_FOUND",
UserMessage: "Order with given id not found",
}
Клиент получает понятное сообщение. err.Error() с внутренними деталями в ответ не попадает.
Персональные данные не в Kafka-событиях
Kafka — широковещательный канал: все зарегистрированные consumer-группы получают каждое сообщение. Если в событии есть email или телефон, они уходят ко всем потребителям — даже тем, которым эти данные не нужны.
// ПЛОХО — PII видят все consumer-группы
type OrderConfirmedEvent struct {
OrderID string `json:"order_id"`
CustomerEmail string `json:"customer_email"` // утечка
CustomerPhone string `json:"customer_phone"` // утечка
TotalAmountKop int64 `json:"total_amount_kop"`
}
// ХОРОШО — только идентификатор
type OrderConfirmedEvent struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
TotalAmountKop int64 `json:"total_amount_kop"`
}
Сервис уведомлений, которому нужен email для отправки письма, запрашивает его отдельно:
func (c *Client) GetContactInfo(ctx context.Context, customerID string) (ContactInfo, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/customers/"+customerID+"/contact", nil)
tok, _ := c.tokenSrc.Token()
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
// ... decode ContactInfo{Email, Phone}
}
Так сервис-источник логирует точечный доступ к PII — кто запросил, когда, для какого клиента. Это управляемо и проверяемо.
Секреты не в репозитории
Секреты — пароль базы данных, ключи API, client_secret OAuth — нельзя хранить в коде или в файлах конфигурации, которые попадают в git.
Правильный способ в Go — читать секреты из переменных окружения через envconfig:
// cmd/app/config.go
import "github.com/kelseyhightower/envconfig"
type Config struct {
DB struct {
DSN string `envconfig:"DB_DSN,required"`
Password string `envconfig:"DB_PASSWORD,required"`
}
S2S struct {
ClientID string `envconfig:"S2S_CLIENT_ID,required"`
ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
TokenURL string `envconfig:"S2S_TOKEN_URL,required"`
}
}
func loadConfig() (Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return Config{}, fmt.Errorf("config: %w", err)
}
return cfg, nil
}
Тег required гарантирует: приложение не запустится, если переменная не задана. Нет риска случайно запустить прод без секрета.
В .gitignore нужно явно исключить файлы с секретами:
.env
.env.local
*.pem
*.key
*-secret.yml
application-prod.yml
Для локальной разработки удобно использовать .env-файл с godotenv:
// cmd/app/main.go
import "github.com/joho/godotenv"
func main() {
_ = godotenv.Load() // молча игнорирует отсутствие .env в продакшене
cfg, _ := loadConfig()
// ...
}
В продакшене переменные окружения задаются через Kubernetes secretKeyRef или Vault Agent Injector.
Важно: если секрет оказался в git-истории, его нужно ротировать немедленно. git reset или git filter-repo не помогут — значение уже могли получить из fork, CI-кэша или прямого клонирования.
Частые ошибки
slog.String("email", email) — вместо этого slog.String("customer_id", id) или маскированное значение через pii.MaskEmail.
PII в error.Error() — структура ошибки хранит только идентификатор агрегата, не его персональные поля.
err.Error() в problem.detail — вместо этого заранее написанное UserMessage или общая фраза "an unexpected error occurred".
PII-поля в Kafka-событии — вместо этого только customer_id; PII запрашивается точечно тем, кому нужно.
Секрет в коде или YAML-файле — вместо этого envconfig из переменной окружения.
slog.Any("customer", c) без LogValue() — slog выведет все поля структуры; нужно реализовать slog.LogValuer.
Коротко
- PII — email, телефон, ФИО, адрес — нельзя писать в
slog-атрибуты ни на каком уровне логирования. - Если нужна диагностика — маскируй через
pii.MaskEmail/pii.MaskPhoneизcore/pii/. - Структуры ошибок хранят только идентификаторы, не PII-поля.
err.Error()не попадает вproblem.detail— только заранее написанныйUserMessageили общая фраза.- Kafka-события содержат только
customer_id; email и телефон запрашивает тот сервис, которому они нужны. - Секреты читаются из переменных окружения через
envconfig;required-тег не даст запустить приложение без них. - Секрет в git-истории — ротировать немедленно, удаление коммита не помогает.
Что почитать дальше
- Аудит admin-команд — как писать в журнал аудита без PII в plain-полях.
- JWT validation — проверка токена и маппинг на 401 без раскрытия причины.
- Владение ресурсом (ABAC) —
ForbiddenErrorбез PII в строке. - Service-to-service —
oauth2.TokenSourceи безопасная передача токенов между сервисами.