← назад к разделу

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

Что почитать дальше