Описание решений по ИБ
Документ содержит описание решений, принятых при реализации нефункциональных требований по информационной безопасности (НФТ) в сервисе rest-proxy: перечень реализованных функций и решённых задач с обоснованиями.
Содержание
Защита от CSRF-атак
1.1 Анализ необходимости защиты от CSRF
Защита от HTTP Parameter Pollution
2.1 Защита от HTTP Parameter Pollution
2.2 Задокументировать политику обработки дублирующихся параметров
2.3 Client-side HPP N/A
2.4 Валидация критичных query-параметров
2.5 Добавить валидацию дублирующихся ключей JSON-тела (Should)
2.6 Зарегистрировать HPP middleware в
handler/api.go2.7 Создать HPP Middleware
Регистрация событий безопасности
3.1 Регистрация событий безопасности
3.2 Добавить событие для
POST /registry/set3.3 Добавить события управления событиями безопасности (жизненный цикл)
3.4 Расширить
authmiddleware: логировать3.5 Реализовать мониторинг потерянных событий
3.6 Создать
SecurityLoggingMiddlewareдля регистрации каждого запроса3.7 Создать пакет
pkg/seclog— ядро журнала событий безопасности
Состав обязательных полей записи события
4.1 Состав обязательных полей записи события
4.2 Инициализировать
actor_ip,actor_hostname,target_ip,target_hostname4.3 Создать
pkg/seclog/event.go— типSecurityEventс 18 обязательными полями4.4 Создать
pkg/seclog/ip.go— корректное извлечениеsource_ip4.5 Создать
pkg/seclog/writer.go— структурированная запись с корректным timestamp
Хранение событий безопасности
5.1 Хранение событий безопасности
5.2 Перевести хранилище событий на JSON Lines
1. Защита от CSRF-атак
1.1 Анализ необходимости защиты от CSRF
Требование
В приложении должна быть реализована защита от CSRF-атак.
Описание проблемы
REST-прокси использует исключительно заголовочную аутентификацию (Authorization: Bearer и X-Api-Key) — cookie не применяются. Это технически устраняет классический CSRF-вектор: браузер не добавляет произвольные заголовки автоматически, как это происходит с cookie. Подход является обоснованным исключением из требования о CSRF-защите. Проблема в том, что исключение нигде не задокументировано, а CORS-конфигурация оставлена открытой: AllowOrigins не задан, что разрешает запросы с любого источника. Это создаёт три риска:
Аудиторский риск: при проверке безопасности система выглядит как не защищённая от CSRF — нет ни токена, ни документа, объясняющего, почему он не нужен.
Риск расширения атаки: открытый CORS в сочетании с будущими изменениями (например, если будет добавлена cookie-сессия) может без предупреждения открыть полноценный CSRF-вектор.
Риск AllowCredentials: true: если в будущем CORS будет настроен с AllowCredentials: true при открытом AllowOrigins, браузер сможет отправлять запросы с заголовками авторизации со скриптов с любых доменов — это полноценный CSRF-вектор даже без cookie.
Контекст кода
Аутентификация — заголовки, не cookie (auth/auth.go:42–47):
func extractKey(r *http.Request) string { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(h[len("Bearer "):]) } return r.Header.Get("X-Api-Key") }
Timing-safe сравнение реализовано корректно (auth/auth.go:51): subtle.ConstantTimeCompare. Открытый CORS (handler/api.go:59–70):
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowHeaders: []string{ echo.HeaderOrigin, echo.HeaderContentType, // ... }, // AllowOrigins НЕ задан → разрешены все источники (*) }))
Задачи
Задача 1 — Задокументировать архитектурное решение об отсутствии CSRF-токена
Как решает проблему: устраняет аудиторский риск. Фиксирует, что защита основана на Bearer-аутентификации без cookie — намеренное архитектурное решение, а не упущение. При будущих изменениях разработчик явно увидит инвариант и не сломает его молча. Что делать: В rest-proxy/README.md добавить секцию:
## CSRF Protection Decision REST API аутентифицируется исключительно через HTTP-заголовки: - `Authorization: Bearer <key>` - `X-Api-Key: <key>` Cookie-аутентификация не используется и не планируется. Синхронизированный CSRF-токен не требуется, поскольку браузер не добавляет произвольные заголовки автоматически — CSRF-вектор технически отсутствует. **Важно:** если в будущем будет введена cookie-аутентификация, необходимо реализовать CSRF-токены (Double Submit Cookie или Synchronizer Token Pattern) до выкладки изменений в production.
Задача 2 — Ограничить CORS AllowOrigins
Как решает проблему: устраняет риск расширения атаки. Открытый CORS (*) означает, что любой сайт может делать cross-origin запросы к API. Ограничение списка источников — это defence-in-depth: при случайном появлении cookie или изменении модели аутентификации атака будет невозможна. Что делать: Изменить handler/api.go, заменить блок CORS:
// было: e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowHeaders: []string{...}, })) // стало: allowOrigins := strings.Split(os.Getenv("CORS_ALLOW_ORIGINS"), ",") // strings.Split("", ",") возвращает []string{""}, не nil — проверяем первый элемент if len(allowOrigins) == 1 && allowOrigins[0] == "" { service.log.Warn("CORS_ALLOW_ORIGINS not set — CORS will be restrictive (no origins allowed)") allowOrigins = []string{} } e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: allowOrigins, AllowHeaders: []string{ echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, echo.HeaderIfModifiedSince, echo.HeaderLastModified, "SF-App-ID", "Pragma", "Cache-Control", }, }))
Добавить переменную в .env и docker-compose.yaml:
CORS_ALLOW_ORIGINS=https://contracteum.internal,https://admin.contracteum.internal
Задача 3 — Добавить middleware проверки Origin/Referer (Should)
Как решает проблему: дополнительный слой защиты поверх Bearer-аутентификации. Даже если токен утечёт через XSS, запрос с постороннего Origin будет отклонён с логированием. Запросы без заголовка Origin (server-to-server, Postman) пропускаются. Что делать: Создать handler/middleware_origin.go:
package handler import ( "net/http" "net/url" "strings" "github.com/labstack/echo" "github.com/sirupsen/logrus" ) func OriginValidationMiddleware(log *logrus.Entry, allowedOrigins []string) echo.MiddlewareFunc { allowed := make(map[string]struct{}, len(allowedOrigins)) for _, o := range allowedOrigins { // Нормализуем: убираем trailing slash, приводим к нижнему регистру normalized := strings.ToLower(strings.TrimRight(strings.TrimSpace(o), "/")) allowed[normalized] = struct{}{} } return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { origin := c.Request().Header.Get("Origin") if origin == "" { // Referer содержит полный URL — извлекаем только scheme://host. // Принимаем только http/https: javascript:void(0) и data:text/html,... // имеют пустой Host и обойдут проверку, если не фильтровать по схеме. if ref := c.Request().Header.Get("Referer"); ref != "" { if u, err := url.Parse(ref); err == nil && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") { origin = strings.ToLower(u.Scheme + "://" + u.Host) } } } else { // Валидируем схему заголовка Origin напрямую — нестандартный клиент может // прислать Origin: javascript:evil или Origin: //evil.com (без схемы). // Браузеры всегда ставят только http/https, поэтому строгая проверка безопасна. if u, err := url.Parse(origin); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return echo.NewHTTPError(http.StatusForbidden, "invalid origin") } origin = strings.ToLower(strings.TrimRight(origin, "/")) } // Запросы без Origin (server-to-server, Postman) пропускаем if origin == "" { return next(c) } if _, ok := allowed[origin]; !ok { log.WithFields(logrus.Fields{ "origin": origin, "remote_addr": c.RealIP(), "path": c.Request().URL.Path, }).Warn("request from untrusted origin rejected") return echo.NewHTTPError(http.StatusForbidden, "forbidden origin") } return next(c) } } }
Зарегистрировать в handler/api.go после CORS middleware:
if len(allowOrigins) > 0 { e.Use(OriginValidationMiddleware(service.log, allowOrigins)) }
Задача 4 — Предупреждение при обнаружении cookie в запросах (Should)
Как решает проблему: реализует раннее предупреждение — если какой-либо клиент начнёт отправлять cookie, это будет немедленно видно в логах до того, как станет уязвимостью. Фиксирует инвариант «cookie не используются» непосредственно в коде. Что делать: Добавить в handler/middleware_origin.go:
func CookieAbsenceWarningMiddleware(log *logrus.Entry) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if cookies := c.Request().Cookies(); len(cookies) > 0 { names := make([]string, len(cookies)) for i, ck := range cookies { names[i] = ck.Name } log.WithFields(logrus.Fields{ "cookie_names": names, "remote_addr": c.RealIP(), "path": c.Request().URL.Path, }).Warn("unexpected cookies in request — CSRF risk if cookie auth is ever introduced") } return next(c) } } }
Зарегистрировать в handler/api.go:
e.Use(CookieAbsenceWarningMiddleware(service.log))
2. Защита от HTTP Parameter Pollution
2.1 Защита от HTTP Parameter Pollution
Требование
В приложении должны применяться механизмы защиты от атак «загрязнения» параметров HTTP (HTTP parameter pollution).
Архитектурное исключение — исходящие вызовы (N/A)
Все исходящие вызовы rest-proxy к Hyperledger Fabric выполняются через Fabric Gateway SDK по gRPC-протоколу (gateway/router.go, gateway/ca.go, gateway/registry.go). HTTP URL с query-строкой не формируются. Пользовательские данные передаются как сериализованный JSON в payload транзакции чейнкода (transient-map). Инъекция через дублирование URL-параметров на уровне gateway невозможна по архитектуре.
Описание проблемы
HTTP Parameter Pollution — атака, при которой злоумышленник передаёт один и тот же query-параметр несколько раз в одном запросе (?status=active&status=revoked). Веб-фреймворки обрабатывают дубликаты по-разному: Echo возвращает первое значение, Go url.Values хранит все значения в слайсе. Это расхождение создаёт вектор для обхода валидации и фильтрации. В rest-proxy отсутствует три элемента защиты:
Нет задокументированной политики обработки дублирующихся параметров.
Нет логирования дублирования параметров — попытка HPP-атаки проходит незамеченной.
Нет дополнительной валидации для критичных параметров — orgID теоретически может быть передан через query string в обход env-переменной; accreditationID не проверяется на формат UUID. Конкретный риск для rest-proxy (query string): эндпоинт GET /ca/:ogrn/accreditation/by-date принимает accreditationID и date через query string (handler/ca.go:392–393). Передача дублирующихся значений позволяет зондировать поведение chaincode при разных комбинациях параметров. Дополнительный риск (тело JSON-запроса): POST-эндпоинты (/registry/set, /ca/create, /certificate/create и др.) используют ctx.Bind() с encoding/json. При дублировании ключей в JSON-теле ({"status":"active","status":"revoked"}) encoding/json использует LAST VALUE — это поведение противоположно политике FIRST VALUE из HPP middleware и не покрывается им, поскольку middleware работает только с query string. Верификация в handler/registry.go:57–79: setRegistry полностью построен на ctx.Bind(), query-параметров не использует.
Контекст кода
Чтение параметров без HPP-защиты (handler/ca.go:408–416):
request := dto.ListCARequest{ AccreditationDateFrom: ctx.QueryParam("accreditationDateFrom"), AccreditationDateTo: ctx.QueryParam("accreditationDateTo"), // ... Status: ctx.QueryParam("status"), }
Критичный параметр без проверки формата (handler/ca.go:395–400):
getCAResp, err := svc.caService.CheckAccreditationByDate(&api.CheckCAAccreditationsByDateRequest{ ID: uuidResp.UUID, AccreditationID: ctx.QueryParam("accreditationID"), Datetime: ctx.QueryParam("date"), })
Echo ctx.QueryParam() всегда возвращает первое значение из url.Values[key][0]. Это поведение не задокументировано как намеренная политика.
2.2 Задокументировать политику обработки дублирующихся параметров
Как решает проблему: переводит случайное поведение фреймворка в явно задокументированное архитектурное решение. При аудите безопасности наличие документации означает разницу между «нет политики» и «политика принята и обоснована». Что делать: В rest-proxy/README.md добавить секцию:
## HTTP Parameter Pollution Policy **Query string (GET-параметры):** rest-proxy применяет политику **FIRST VALUE**. При наличии нескольких значений одного параметра (`?status=a&status=b`) используется первое значение, остальные отбрасываются. Каждое дублирование логируется как событие `HPP-001`. При >3 дублях запрос отклоняется с HTTP 400 (`HPP-002`). Реализовано в `handler/middleware_hpp.go` → `HPPMiddleware`. **JSON-тело (POST-параметры):** `encoding/json` использует LAST VALUE для дублирующихся ключей — намеренное поведение стандартной библиотеки Go. Критичные поля (orgID) получаются из env-переменных, не из тела запроса, поэтому риск минимален. При добавлении новых эндпоинтов с JSON-телом использовать строгую валидацию схемы или `json.Decoder.DisallowUnknownFields()`.
2.3 Client-side HPP N/A
Вывод по коду Проверено: gateway/router.go, gateway/ca.go, gateway/registry.go — все внешние вызовы идут через Hyperledger Fabric Gateway SDK по gRPC-протоколу. HTTP URL с query-параметрами не конструируются. Пользовательские данные передаются как JSON-аргументы функций чейнкода, не как URL-параметры. Требование неприменимо к текущей реализации rest-proxy. Что нужно сделать Добавить в проектную документацию раздел: - Сlient-side HPP (NFR-SEC-002.4) — статус: N/A Все внешние вызовы rest-proxy выполняются через Hyperledger Fabric Gateway SDK (gRPC). HTTP URL с query-строкой не формируются. Параметры пользователя передаются как сериализованный JSON в payload транзакции чейнкода. Инъекция через дублирование URL-параметров на уровне gateway невозможна по архитектуре. Дата проверки: 2026-05-25. Пересмотреть при добавлении HTTP-вызовов во внешние системы.
2.4 Валидация критичных query-параметров
Как решает проблему: orgID — критичный параметр авторизации, определяющий, от имени какой организации выполняется операция. Его получение из query string запрещено: он должен приходить только из env (ORG). Параметры типа accreditationID должны соответствовать UUID-формату, чтобы предотвратить передачу произвольных строк в chaincode. Что делать: Создать handler/middleware_param_guard.go:
package handler import ( "net/http" "regexp" "github.com/labstack/echo" "github.com/sirupsen/logrus" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" ) var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // blockedQueryParams — параметры, которые никогда не должны приходить из query string. var blockedQueryParams = []string{"orgID", "org_id", "mspID", "msp_id"} // CriticalParamGuardMiddleware блокирует передачу параметров авторизации через query string. func CriticalParamGuardMiddleware(log *logrus.Entry) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { for _, param := range blockedQueryParams { if val := c.QueryParam(param); val != "" { log.WithFields(logrus.Fields{ "event_id": seclog.EventHPPAuthParam, "param": param, "source_ip": c.RealIP(), "path": c.Request().URL.Path, }).Error("authorization parameter passed via query string — request rejected") return echo.NewHTTPError(http.StatusBadRequest, "authorization parameters are not accepted via query string") } } return next(c) } } } // ValidateUUIDParam проверяет, что query-параметр соответствует формату UUID. // Используется в хендлерах для критичных ID перед передачей в chaincode. func ValidateUUIDParam(c echo.Context, param string) (string, error) { val := c.QueryParam(param) if val == "" { return "", nil } if !uuidRe.MatchString(val) { return "", echo.NewHTTPError(http.StatusBadRequest, "invalid format for parameter: "+param) } return val, nil }
В хендлерах, использующих accreditationID, заменить прямое чтение:
// было: AccreditationID: ctx.QueryParam("accreditationID"), // стало: accreditationID, err := ValidateUUIDParam(ctx, "accreditationID") if err != nil { return err } AccreditationID: accreditationID,
2.5 Добавить валидацию дублирующихся ключей JSON-тела (Should)
Как решает проблему: закрывает вектор HPP в теле JSON-запроса. encoding/json использует LAST VALUE для дублирующихся ключей, что противоречит политике FIRST VALUE из HPP middleware. Для критичных POST-эндпоинтов (/registry/set, /ca/create) необходима дополнительная защита. Что делать: Создать handler/middleware_json_strict.go:
package handler import ( "bytes" "encoding/json" "io" "net/http" "github.com/labstack/echo" "github.com/sirupsen/logrus" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" ) // StrictJSONMiddleware отклоняет JSON-тела с дублирующимися ключами. // Применять только к POST-эндпоинтам с критичными операциями. func StrictJSONMiddleware(log *logrus.Entry) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if c.Request().Body == nil { return next(c) } body, err := io.ReadAll(c.Request().Body) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "cannot read request body") } c.Request().Body = io.NopCloser(bytes.NewReader(body)) if hasDuplicateKeys(body) { log.WithFields(logrus.Fields{ "event_id": seclog.EventHPPDuplicateJSONKey, "source_ip": c.RealIP(), "path": c.Request().URL.Path, }).Warn("duplicate JSON keys detected — request rejected") return echo.NewHTTPError(http.StatusBadRequest, "duplicate keys in JSON body") } return next(c) } } } // hasDuplicateKeys рекурсивно обходит JSON через Token() API. // Каждый уровень вложенности `{...}` получает независимую карту seen — // одинаковые ключи на разных уровнях не считаются дублями. // // Ограничение: тело запроса верхнего уровня должно быть объектом `{...}`. // Массив верхнего уровня `[{"k":1,"k":2}]` детектируется через checkArrayBody. func hasDuplicateKeys(data []byte) bool { dec := json.NewDecoder(bytes.NewReader(data)) tok, err := dec.Token() if err != nil { return false } if d, ok := tok.(json.Delim); ok { if d == '{' { return checkObjectBody(dec) } if d == '[' { return checkArrayBody(dec) } } return false } // checkObjectBody читает тело объекта (после открывающего `{`). func checkObjectBody(dec *json.Decoder) bool { seen := make(map[string]struct{}) for { tok, err := dec.Token() if err != nil { return false } if d, ok := tok.(json.Delim); ok && d == '}' { return false } key, ok := tok.(string) if !ok { return false } if _, exists := seen[key]; exists { return true } seen[key] = struct{}{} if checkValue(dec) { return true } } } // checkValue читает одно значение (примитив, объект или массив). func checkValue(dec *json.Decoder) bool { tok, err := dec.Token() if err != nil { return false } switch v := tok.(type) { case json.Delim: if v == '{' { return checkObjectBody(dec) } if v == '[' { return checkArrayBody(dec) } } return false } // checkArrayBody читает тело массива (после открывающего `[`). // При получении примитивного токена (string, float64, bool, nil) не совпадает // ни одна ветка — выполнение падает в конец тела цикла и неявно возвращается // к следующей итерации (семантика Go: конец тела for {} эквивалентен continue). // Цикл завершается только через явные return или через err != nil при EOF. func checkArrayBody(dec *json.Decoder) bool { for { tok, err := dec.Token() if err != nil { return false // io.EOF или ошибка — дублей не найдено } d, ok := tok.(json.Delim) if !ok { continue // примитивное значение (string, float64, bool, nil) — следующая итерация } switch d { case ']': return false case '{': if checkObjectBody(dec) { return true } case '[': if checkArrayBody(dec) { return true } } } }
Применить к критичным группам в handler/api.go:
registry := e.Group("/registry") registry.Use(StrictJSONMiddleware(service.log)) registry.POST("/set", service.setRegistry)
Покрытие и известные ограничения:
Эндпоинт | Защита StrictJSON | Оценка риска |
POST /registry/set | ✅ | Высокий: меняет chaincode-маршруты всей системы |
POST /ca/create | ❌ | Средний: orgID из env; поля ogrn , name — строки без критичной бизнес-логики на дублях |
POST /certificate/create | ❌ | Средний: аналогично; encoding/json LAST VALUE на полях сертификата |
POST /certificate/:id/public-update | ❌ | Средний: статус отзыва — стоит рассмотреть добавление middleware |
POST /digital-signature/create | ❌ | Низкий: подпись вычисляется из тела, дубль ключа не меняет результат подписи |
POST /acl/set | ❌ | Высокий: управление правами доступа — рекомендуется добавить middleware |
POST /acl/registry/set | ❌ | Высокий: аналогично /acl/set |
Рекомендуется расширить |
2.6 Зарегистрировать HPP middleware в handler/api.go
Как решает проблему: активирует защиту для всех эндпоинтов сразу. Middleware уровня приложения гарантирует, что ни один новый хендлер не окажется без HPP-защиты по умолчанию. Что делать: В handler/api.go, метод Start(). Ниже показан итоговый порядок всех middleware:
e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(...)) e.Use(service.authMiddleware) e.Use(OriginValidationMiddleware(...)) e.Use(CookieAbsenceWarningMiddleware(service.log)) e.Use(HPPMiddleware(service.log)) e.Use(CriticalParamGuardMiddleware(service.log)) e.Use(middleware.BodyDumpWithConfig(...)) e.Use(SecurityLoggingMiddleware( service.secLogger, service.orgID, service.trustedProxies)) // затем группы роутов: ca, cert, registry, ds, acl
Порядок критичен: CORS обязан быть первым после Recover — браузер отправляет preflight OPTIONS без заголовков авторизации. Если authMiddleware стоит до CORS, все preflight-запросы получат HTTP 401 и сломают cross-origin клиентов. SecurityLoggingMiddleware стоит после HPP и ParamGuard, чтобы логировать уже нормализованный запрос.
2.7 Создать HPP Middleware
Как решает проблему: каждый дублирующийся параметр фиксируется в журнале. Средняя пентест-атака становится видимой в мониторинге. Явная политика FIRST VALUE в коде переводит случайное поведение фреймворка в задокументированный механизм защиты. Что делать: Создать handler/middleware_hpp.go:
package handler import ( "fmt" "net/http" "github.com/labstack/echo" "github.com/sirupsen/logrus" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" ) // HPPMiddleware применяет политику FIRST VALUE для дублирующихся query-параметров // и логирует каждое дублирование как событие безопасности. func HPPMiddleware(log *logrus.Entry) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { queryValues := c.Request().URL.Query() suspicious := false for param, values := range queryValues { if len(values) <= 1 { continue } // Политика FIRST VALUE: копируем первый элемент в новый слайс, // чтобы не сохранять ссылку на оригинальный backing array. queryValues[param] = []string{values[0]} log.WithFields(logrus.Fields{ "event_id": seclog.EventHPPDuplicate, "param": param, "values_count": len(values), "first_value": values[0], "source_ip": c.RealIP(), "path": c.Request().URL.Path, "operation_detail": fmt.Sprintf("duplicate param '%s': %d values", param, len(values)), }).Warn("HPP detected: duplicate query parameter") if len(values) > 3 { suspicious = true } } // url.Values.Encode() сортирует параметры алфавитно и повторно URL-кодирует значения. // Это безопасно для данного случая, но стоит учитывать при отладке. c.Request().URL.RawQuery = queryValues.Encode() if suspicious { // Удаляем входящий X-HPP-Suspicious — клиент мог прислать его сам, // эмулируя внутренний маркер подозрительного запроса. // Собственный заголовок не выставляется: запрос сразу отклоняется ниже. c.Request().Header.Del("X-HPP-Suspicious") // Блокируем запрос с >3 дублями — логирование без реакции создаёт ложное ощущение защиты. log.WithFields(logrus.Fields{ "event_id": seclog.EventHPPSuspicious, "source_ip": c.RealIP(), "path": c.Request().URL.Path, }).Error("HPP suspicious: >3 duplicate values — request rejected") return echo.NewHTTPError(http.StatusBadRequest, "too many duplicate query parameters") } return next(c) } } }
3. Регистрация событий безопасности
3.1 Регистрация событий безопасности
Требование
В ПО должна обеспечиваться регистрация событий безопасности. Состав, содержание и объём событий безопасности определён ГОСТ 59548-2022. Основными категориями действий, для которых требуется регистрация, являются:
аутентификация и авторизация пользователей;
управление пользователями и конфигурациями;
управление событиями безопасности в системе.
Описание проблемы
Во всём коде rest-proxy существует ровно одна строка, которую можно назвать событием безопасности:
// auth/auth.go:35 log.WithField("remote_addr", c.RealIP()).Warn("unauthorized request")
Система слепа к подавляющему большинству значимых для безопасности событий. Если злоумышленник систематически перебирает API-ключи, выпускает аномальное количество сертификатов или меняет конфигурацию chaincode-маршрутов — ничего из этого не попадает в журнал как событие безопасности. Покрытие трёх категорий требования:
Аутентификация и авторизация: покрыто частично — только неудачная аутентификация, и то без обязательных полей. Успешная аутентификация не регистрируется.
Управление пользователями и конфигурациями: покрыто 0 событий. В частности, POST /registry/set — регистрация нового chaincode-маршрута — является операцией изменения конфигурации системы и не логируется вообще.
Управление событиями безопасности: покрыто 0 событий. Запуск и остановка подсистемы журналирования не регистрируются. Дополнительно: при сбое механизма логирования нет резервного канала — события могут теряться без предупреждения.
Контекст кода
Единственное событие безопасности (auth/auth.go:35):
log.WithField("remote_addr", c.RealIP()).Warn("unauthorized request")
Операционные логи в хендлерах — не являются событиями безопасности (handler/ca.go:16–19):
log := svc.log.WithFields(logrus.Fields{"method": "createCA"}) log.Infof("serving")
Отсутствуют: event_id, operation_type, subject_id, source_ip, operation_status. Отключённый bodyDumpHandler (handler/middleware.go:6–9):
func (service *APIService) bodyDumpHandler(ctx echo.Context, reqBody, resBody []byte) { // Закомментировано }
Тела запроса/ответа правильно не логируются (маскирование секретов), но само событие обработки запроса не регистрируется.
3.2 Добавить событие для POST /registry/set
Как решает проблему: закрывает пробел в категории «управление конфигурациями». POST /registry/set регистрирует новый chaincode-маршрут — это изменение конфигурации маршрутизации всей системы. Такие операции требуют отдельной регистрации, поскольку компрометация этого эндпоинта означает перенаправление трафика на подложный chaincode. Что делать: Добавить в handler/registry.go необходимые импорты (используются в новом коде setRegistry):
import ( // ... существующие импорты ... "fmt" "time" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" // "net/http" и "gitlab.procsy.ru/rr-pki/rest-proxy/dto" уже присутствуют в реальном файле )
В handler/registry.go, хендлер setRegistry, добавить явную запись события после успешного вызова. В registry.go используется dto.NewErrorResponse — функция mapChaincodeError существует в handler/chaincode_errors.go, но в реестре не применяется. Оба подхода валидны; для единообразия с текущим кодом registry.go используем dto.NewErrorResponse:
func (svc *APIService) setRegistry(ctx echo.Context) error { // ... существующая логика Bind() ... createResp, err := svc.registryService.Set(&req) if err != nil { // событие неудачи фиксируется SecurityLoggingMiddleware response := dto.NewErrorResponse(err) return ctx.JSON(http.StatusInternalServerError, response) } // Явное событие конфигурации — дополнительно к middleware svc.secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: time.Now(), EventID: seclog.EventConfigRegistrySet, EventName: "REGISTRY_CONFIG_CHANGED", OperationType: string(seclog.OpCreate), OperationDetail: fmt.Sprintf("chaincode registry registered: channel=%s chaincode=%s", req.Channel, req.Chaincode), SubjectID: svc.orgID, ObjectType: "REGISTRY", ObjectID: req.OGRN, SessionID: ctx.Request().Header.Get("SF-App-ID"), OperationStatus: "SUCCESS", SourceIP: seclog.GetRealIP(ctx.Request(), svc.trustedProxies), }) return ctx.JSON(http.StatusOK, createResp) }
Примечание о двойном логировании: SecurityLoggingMiddleware также записывает для POST /registry/set через ClassifyEvent. Это намеренно: middleware фиксирует HTTP-факт запроса (метод, путь, статус), хендлер фиксирует бизнес-факт (конкретный channel и chaincode-маршрут). SIEM-системы различают записи по полям operation_detail и object_id. Такой audit trail является best practice и не является дублированием в смысле ошибки.
3.3 Добавить события управления событиями безопасности (жизненный цикл)
Как решает проблему: закрывает категорию «управление событиями безопасности в системе». Без событий запуска/остановки невозможно проверить полноту журнала: если журнал не начинается с AUDSYS-001, значит записи могли быть удалены. Что делать: В main.go, после инициализации secLogger, перед стартом сервера:
secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: time.Now(), EventID: seclog.EventAudsysStart, EventName: seclog.EventNameForID(seclog.EventAudsysStart), OperationType: string(seclog.OpExecute), OperationDetail: "rest-proxy security logging subsystem initialized", SubjectID: orgID, ObjectType: "SERVICE", ObjectID: "rest-proxy", OperationStatus: "SUCCESS", // actorIP, actorHostname, targetIP, targetHostname инициализированы. // Logger автоматически заполняет эти поля из своей конфигурации — явно передавать не нужно. })
Добавить graceful shutdown с событием AUDSYS-002. Критично: handler/api.go:120 (метод Start()) содержит service.log.Fatal(e.Start(...)). logrus.Fatal вызывает os.Exit(1) сразу после возврата e.Start, не давая shutdown-горутине записать AUDSYS-002. Необходимо заменить в handler/api.go, метод Start():
// было (handler/api.go, метод Start(), строка 120): service.log.Fatal(e.Start(service.address)) // стало: if err := e.Start(service.address); err != nil && !errors.Is(err, http.ErrServerClosed) { service.log.Fatal(err) } // После этой строки e.Shutdown() уже завершился — AUDSYS-002 записывается корректно.
Graceful shutdown и запись AUDSYS-002 реализуются внутри метода Start() в handler/api.go — там переменная e (echo.Echo) находится в области видимости. Размещение shutdown-горутины в main.go невозможно: e объявлен внутри Start() и недоступен снаружи. Добавить в импорты handler/api.go: "context", "errors", "net/http", "os", "os/signal", "strings", "syscall", "time" (плюс "net" и "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" из Задачи 2). "strings" нужен для strings.Split. В метод Start() в handler/api.go, перед строкой с e.Start(), добавить:
monitorDone := make(chan struct{}) // объявляется здесь; используется в Задаче 6 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { <-quit close(monitorDone) // останавливаем monitor-горутину из Задачи 6 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() e.Shutdown(ctx) // e доступен: горутина объявлена внутри Start() }() if err := e.Start(service.address); err != nil && !errors.Is(err, http.ErrServerClosed) { service.log.Fatal(err) } // e.Start() вернул управление — e.Shutdown() уже завершён. // AUDSYS-002 пишется последовательно: race condition исключён по конструкции. service.secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: time.Now(), EventID: seclog.EventAudsysStop, EventName: seclog.EventNameForID(seclog.EventAudsysStop), OperationType: string(seclog.OpExecute), OperationDetail: "rest-proxy shutting down", SubjectID: service.orgID, ObjectType: "SERVICE", ObjectID: "rest-proxy", OperationStatus: "SUCCESS", })
В main.go, после apiService.Start():
apiService.Start(isDebugEnabled) securityLogFile.Close() // Start() записал AUDSYS-002 до возврата — закрывать безопасно
sync.WaitGroup не нужен — последовательная запись AUDSYS-002 внутри Start() после e.Start() устраняет race condition без дополнительной синхронизации.
3.4 Расширить auth middleware: логировать
Как решает проблему: закрывает пробел в категории «аутентификация и авторизация». Сейчас только неудача логируется, и то без структуры. После изменения оба события (успех и неудача) будут содержать полный набор обязательных полей. Что делать: В auth/auth.go, метод newMiddleware, заменить тело внутренней функции:
return func(c echo.Context) error { key := extractKey(c.Request()) if !isValid([]byte(key), keys) { reason := "API key not found in authorized keys" secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: time.Now(), EventID: seclog.EventAuthFailure, EventName: seclog.EventNameForID(seclog.EventAuthFailure), SubjectName: "", OperationType: string(seclog.OpLogin), OperationDetail: "Invalid API key presented", SubjectID: "ANONYMOUS", ObjectType: "API_ENDPOINT", ObjectID: c.Request().URL.Path, SessionID: "UNKNOWN", OperationStatus: "FAILURE", FailureReason: &reason, SourceIP: seclog.GetRealIP(c.Request(), trustedProxies), }) log.WithField("remote_addr", c.RealIP()).Warn("unauthorized request") return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized") } secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: time.Now(), EventID: seclog.EventAuthSuccess, EventName: seclog.EventNameForID(seclog.EventAuthSuccess), SubjectName: "", OperationType: string(seclog.OpLogin), OperationDetail: "API key authenticated", SubjectID: "AUTHENTICATED", ObjectType: "API_ENDPOINT", ObjectID: c.Request().URL.Path, SessionID: c.Request().Header.Get("SF-App-ID"), OperationStatus: "SUCCESS", SourceIP: seclog.GetRealIP(c.Request(), trustedProxies), }) return next(c) }
Добавить в auth/auth.go необходимые импорты (используются в новом теле newMiddleware):
import ( // ... существующие импорты ... "net" "time" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" )
Обновить сигнатуру NewMiddleware в auth/auth.go:
func NewMiddleware(log *logrus.Entry, secLogger *seclog.Logger, trustedProxies []net.IPNet) (echo.MiddlewareFunc, error)
Обновить сигнатуру и вызов внутренней функции newMiddleware в auth/auth.go:
// сигнатура: func newMiddleware(keys [][]byte, log *logrus.Entry, secLogger *seclog.Logger, trustedProxies []net.IPNet) echo.MiddlewareFunc // вызов внутри NewMiddleware (заменить строку "return newMiddleware(keys, log), nil"): return newMiddleware(keys, log, secLogger, trustedProxies), nil
Обновить вызов в main.go (строка 73 оригинала):
// было: authMiddleware, err := auth.NewMiddleware(log.WithField("service", "auth")) // стало: authMiddleware, err := auth.NewMiddleware(log.WithField("service", "auth"), secLogger, trustedProxies)
3.5 Реализовать мониторинг потерянных событий
Как решает проблему: при сбое основного хранилища события уходят в fallback (stderr), но счётчик потерь позволяет оператору узнать о проблеме без ручного анализа логов. Без мониторинга потерь неисправность журналирования остаётся незамеченной. Что делать: Горутину мониторинга добавить внутри метода Start() в handler/api.go — там monitorDone уже объявлен и доступен в замыкании. Размещение в main.go потребовало бы передачи monitorDone как параметра.
// Добавить сразу после объявления monitorDone, до запуска shutdown-горутины: go func() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() // предотвращаем утечку горутины при завершении for { select { case <-ticker.C: if n := service.secLogger.DroppedCount(); n > 0 { service.log.Errorf("security log: %d events dropped — check log storage", n) } case <-monitorDone: return } } }() // close(monitorDone) вызывается в shutdown-горутине из Задачи 4. // Повторный вызов не нужен — закрытие уже закрытого канала вызовет runtime panic.
3.6 Создать SecurityLoggingMiddleware для регистрации каждого запроса
Как решает проблему: охватывает все три категории требования одним middleware. Каждый HTTP-запрос — это либо операция аутентификации, либо управление конфигурацией, либо операция с данными. Факт выполнения (успех/неудача) фиксируется после завершения хендлера, когда известен реальный статус ответа. Что делать: Создать rest-proxy/handler/middleware_seclog.go:
package handler import ( "errors" "fmt" "net" "net/http" "time" "github.com/labstack/echo" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" ) func SecurityLoggingMiddleware(secLogger *seclog.Logger, orgID string, trustedProxies []net.IPNet) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { startTime := time.Now() err := next(c) status := "SUCCESS" var failureReason *string // c.Response().Status может быть 0, если хендлер вернул echo.HTTPError — // Echo устанавливает статус ответа только после завершения всей middleware chain. // Извлекаем код напрямую из ошибки через type assertion. httpStatus := c.Response().Status if err != nil { var he *echo.HTTPError if errors.As(err, &he) { httpStatus = he.Code } } if err != nil || httpStatus >= 400 { status = "FAILURE" reason := extractReason(err, httpStatus) failureReason = &reason } // AUTH-события (401) уже записаны напрямую через auth.go как AUTH-002. // Дублирующий DATA-* событие от SecurityLoggingMiddleware вводит в заблуждение: // операция с данными не выполнялась — аутентификация не прошла. if httpStatus == http.StatusUnauthorized { return err } eventID := seclog.ClassifyEvent(c.Request().Method, c.Request().URL.Path) secLogger.WriteEvent(seclog.SecurityEvent{ EventTimestamp: startTime, EventID: eventID, EventName: seclog.EventNameForID(eventID), OperationType: string(seclog.OpTypeForMethod(c.Request().Method)), OperationDetail: c.Request().Method + " " + c.Request().URL.Path, SubjectID: orgID, SubjectName: "", ObjectType: "API_ENDPOINT", ObjectID: c.Request().URL.Path, SessionID: c.Request().Header.Get("SF-App-ID"), OperationStatus: status, FailureReason: failureReason, SourceIP: seclog.GetRealIP(c.Request(), trustedProxies), }) return err } } } func extractReason(err error, httpStatus int) string { if err != nil { return err.Error() } return fmt.Sprintf("HTTP %d", httpStatus) }
Добавить в импорты handler/api.go:
import ( // ... существующие импорты ... "net" "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog" )
Добавить поля в структуру APIService (handler/api.go):
type APIService struct { // ... существующие поля ... secLogger *seclog.Logger trustedProxies []net.IPNet }
Обновить сигнатуру и тело NewAPIService:
func NewAPIService( // ... существующие параметры ... secLogger *seclog.Logger, trustedProxies []net.IPNet, ) *APIService { // ... return &APIService{ // ... существующие поля ... secLogger: secLogger, trustedProxies: trustedProxies, } }
Обновить вызов NewAPIService в main.go:
apiService := handler.NewAPIService( // ... существующие аргументы ... secLogger, trustedProxies, )
Зарегистрировать middleware в Start() перед группами роутов:
e.Use(SecurityLoggingMiddleware(service.secLogger, service.orgID, service.trustedProxies))
3.7 Создать пакет pkg/seclog — ядро журнала событий безопасности
Как решает проблему: создаёт единый типизированный механизм записи событий безопасности для всех трёх категорий требования. Все последующие задачи используют этот пакет — консистентность формата гарантируется на уровне типов, разрозненные log.Warn() по всему коду исключены. Резервный канал (stderr) обеспечивает доставку событий при сбое основного хранилища. Что делать: Примечание об архитектуре пакета: тип Logger и методы WriteEvent/tryWrite/DroppedCount определяются только в pkg/seclog/writer.go. Файл event.go содержит только тип SecurityEvent и методы для него. Файл classifier.go содержит только константы и функции классификации. Дублирование type Logger struct в двух файлах одного пакета — ошибка компиляции. Создать rest-proxy/pkg/seclog/classifier.go с константами всех трёх категорий:
package seclog import "strings" // --- Категория 1: Аутентификация и авторизация --- const ( EventAuthSuccess = "AUTH-001" // успешная аутентификация EventAuthFailure = "AUTH-002" // неудачная аутентификация ) // --- Категория 2: Управление конфигурациями --- const ( EventConfigRegistrySet = "CONFIG-001" // POST /registry/set — регистрация chaincode-маршрута ) // CONFIG-002 удалена: событие запуска покрывается AUDSYS-001 (EventAudsysStart). // Если потребуется отдельное событие конфигурации — добавить константу согласованно // (см. «Условие согласования» после кода classifier.go). // --- Категория 3: Управление событиями безопасности --- const ( EventAudsysStart = "AUDSYS-001" // запуск подсистемы журналирования EventAudsysStop = "AUDSYS-002" // остановка подсистемы журналирования ) // --- Управление правами доступа (Категория 2: управление пользователями) --- const ( EventPermACLSet = "PERM-001" // POST /acl/set — установка политики ACL EventPermACLRegSet = "PERM-002" // POST /acl/registry/set — установка ACL реестра ) // --- Операции с данными (сертификаты, подписи) --- const ( EventDataCACreate = "DATA-CA-001" // POST /ca/create EventDataCertIssue = "DATA-UC-001" // POST /certificate/create EventDataCertRevoke = "DATA-UC-002" // POST /certificate/:id/public-update EventDataDSCreate = "DATA-DS-001" // POST /digital-signature/create ) // --- Защита от HPP (HTTP Parameter Pollution) --- const ( EventHPPDuplicate = "HPP-001" // дублирующийся query-параметр (FIRST VALUE применён) EventHPPSuspicious = "HPP-002" // >3 дублирующихся значений — запрос отклонён EventHPPAuthParam = "HPP-003" // параметр авторизации в query string — запрос отклонён EventHPPDuplicateJSONKey = "HPP-004" // дублирующийся ключ в JSON-теле — запрос отклонён ) type OperationType string const ( OpLogin OperationType = "LOGIN" OpCreate OperationType = "CREATE" OpRead OperationType = "READ" OpUpdate OperationType = "UPDATE" OpExecute OperationType = "EXECUTE" ) // ClassifyEvent определяет EventID по HTTP-методу и пути запроса. // path передаётся из c.Request().URL.Path — Echo нормализует его (убирает trailing slash, // разрешает ../ и т.п.) до передачи в middleware, поэтому дополнительная нормализация не нужна. func ClassifyEvent(method, path string) string { switch { case method == "POST" && path == "/registry/set": return EventConfigRegistrySet case method == "POST" && path == "/acl/set": return EventPermACLSet case method == "POST" && path == "/acl/registry/set": return EventPermACLRegSet case method == "POST" && strings.HasPrefix(path, "/ca") && strings.HasSuffix(path, "/create"): return EventDataCACreate case method == "POST" && strings.HasPrefix(path, "/certificate") && strings.HasSuffix(path, "/create"): return EventDataCertIssue case method == "POST" && strings.HasPrefix(path, "/certificate") && strings.HasSuffix(path, "/public-update"): return EventDataCertRevoke case method == "POST" && strings.HasPrefix(path, "/digital-signature"): return EventDataDSCreate default: return "DATA-READ-001" } } // EventNameForID возвращает человекочитаемое имя события по его ID. // EventName и EventID — разные поля: ID машиночитаемый, Name — для журналов и SIEM-правил. func EventNameForID(id string) string { switch id { case EventAuthSuccess: return "USER_LOGIN_SUCCESS" case EventAuthFailure: return "USER_LOGIN_FAILED" case EventConfigRegistrySet: return "REGISTRY_CONFIG_CHANGED" case EventAudsysStart: return "AUDIT_SUBSYSTEM_STARTED" case EventAudsysStop: return "AUDIT_SUBSYSTEM_STOPPED" case EventDataCACreate: return "CA_CREATED" case EventDataCertIssue: return "CERT_ISSUED" case EventDataCertRevoke: return "CERT_STATUS_UPDATED" case EventDataDSCreate: return "DS_CREATED" case EventPermACLSet: return "ACL_POLICY_SET" case EventPermACLRegSet: return "ACL_REGISTRY_POLICY_SET" case EventHPPDuplicate: return "HPP_DUPLICATE_PARAM" case EventHPPSuspicious: return "HPP_SUSPICIOUS_REQUEST" case EventHPPAuthParam: return "HPP_AUTH_PARAM_IN_QUERY" case EventHPPDuplicateJSONKey: return "HPP_DUPLICATE_JSON_KEY" default: return id } } // OpTypeForMethod возвращает тип операции по HTTP-методу. func OpTypeForMethod(method string) OperationType { switch method { case "POST": return OpCreate case "GET": return OpRead case "PUT", "PATCH": return OpUpdate default: return OpExecute } }
⚠️ Условие согласования для разработчиков: при добавлении или удалении константы события в classifier.go необходимо согласованно обновить три места в том же файле:
Блок
const— объявление константыEventNameForID— человекочитаемое имя событияClassifyEvent— если константа соответствует HTTP-маршруту (иначе константа останется мёртвой) Разрозненное обновление (добавить константу безEventNameForIDили наоборот) создаёт мёртвые ветви вswitchи вводит разработчиков в заблуждение. Code review должен проверять все три места одновременно.
4. Состав обязательных полей записи события
4.1 Состав обязательных полей записи события
Требование
Вне зависимости от категории действия регистрируемые события безопасности должны включать следующую основную информацию:
название и идентификатор события;
информацию о выполненной операции (в т.ч. создание, изменение, удаление, просмотр);
точное время возникновения события;
информацию об объекте и субъекте операции;
уникальный идентификатор и имя пользователя в системе;
уникальный идентификатор сессии пользователя в системе;
статус выполненной операции (успех/отказ);
описание причины, в случае неуспешности операции;
IP адрес и имя источника информации о событии;
IP адрес и имя хоста, выполнившего операцию;
IP адрес и имя хоста, на котором зафиксирована операция.
Описание проблемы
Требование определяет 11 групп обязательных атрибутов, которые раскрываются в 18 конкретных полей. Текущее состояние: 0 из 18 полей реализованы полностью. 1 поле реализовано частично: source_ip (только remote_addr без учёта X-Forwarded-For через доверенные прокси). Единственная запись безопасности в системе:
// auth/auth.go:35 log.WithField("remote_addr", c.RealIP()).Warn("unauthorized request")
Этого недостаточно для расследования инцидентов: нет event_id для категоризации, нет event_timestamp в структурированном формате, нет operation_status для фильтрации успехов/отказов, нет сетевых реквизитов (actor_ip, target_ip). Проблема с временными метками (main.go:16): logrus.New() использует TextFormatter по умолчанию. Временная метка выводится как строка в текстовой записи — не структурированно, не пригодно для машинной обработки. Проблема с source_ip (auth/auth.go:33): c.RealIP() в Echo без настроенных TrustedProxies возвращает RemoteAddr — IP прокси или балансировщика нагрузки, а не реального клиента.
Контекст кода
Единственная запись безопасности содержит 1 поле из 18:
// auth/auth.go:35 log.WithField("remote_addr", c.RealIP()).Warn("unauthorized request") // Присутствует: remote_addr (source_ip — частично) // Отсутствует: // event_id, event_name — идентификатор и название // event_timestamp — точное время (структурировано) // operation_type, operation_detail — информация об операции // subject_id, subject_name — субъект операции // object_type, object_id — объект операции // session_id — идентификатор сессии // operation_status, failure_reason — статус и причина // source_hostname — имя источника // actor_ip, actor_hostname — хост, выполнивший операцию // target_ip, target_hostname — хост, на котором зафиксирована операция
4.2 Инициализировать actor_ip, actor_hostname, target_ip, target_hostname
Как решает проблему: покрывает группы 10 и 11 требования — хост, выполнивший операцию (сам rest-proxy), и хост, на котором зафиксирована операция (Fabric Gateway). Без этих полей невозможно коррелировать события между несколькими экземплярами сервиса при разборе инцидента. Что делать: Добавить в main.go после инициализации логера, до создания secLogger. Добавить в импорты main.go: "net", "gitlab.procsy.ru/rr-pki/rest-proxy/pkg/seclog".
// actor = сам сервис rest-proxy actorHostname, err := os.Hostname() if err != nil { actorHostname = "UNKNOWN" log.Warnf("cannot get hostname: %v", err) } actorIP, err := seclog.GetLocalIP() // GetLocalIP экспортирована из pkg/seclog/ip.go if err != nil { actorIP = "UNKNOWN" log.Warnf("cannot get local IP: %v", err) } // target = Fabric Gateway (хост, на котором фиксируется операция) // FABRIC_GATEWAY_ADDRESS может содержать hostname ("peer0.org1.example.com:7051"), а не IP — // задокументированное ограничение: поле target_ip будет содержать hostname в таком случае. fabricGatewayAddr := os.Getenv("FABRIC_GATEWAY_ADDRESS") targetIP, _, _ := net.SplitHostPort(fabricGatewayAddr) if targetIP == "" { targetIP = fabricGatewayAddr } // rDNS для targetHostname: не реализуется в production из соображений latency. // Задокументированное ограничение: targetHostname всегда "UNKNOWN". targetHostname := "UNKNOWN" // Список доверенных прокси для X-Forwarded-For (GetRealIP). // Передаётся в auth.NewMiddleware и handler.NewAPIService trustedProxies := seclog.ParseTrustedProxies()
Добавить вспомогательную функцию:
// GetLocalIP возвращает первый non-loopback IPv4-адрес сервиса. // Экспортирована (заглавная G) — вызывается из package main. // Использует net.InterfaceAddrs() вместо dial к 8.8.8.8 — последнее // не работает в изолированных Docker-контейнерах без доступа в интернет. // // Известное ограничение: при наличии нескольких сетевых интерфейсов (eth0, eth1, docker0) // порядок адресов определяется ядром ОС и не гарантирован. В контейнере с несколькими // интерфейсами actorIP может оказаться адресом bridge-сети (172.17.x.x), а не основного // интерфейса. Если детерминированность критична — задавать ACTOR_IP через env-переменную. func GetLocalIP() (string, error) { if v := os.Getenv("ACTOR_IP"); v != "" { return v, nil // явное переопределение через конфигурацию } addrs, err := net.InterfaceAddrs() if err != nil { return "", err } for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } ip := ipNet.IP.To4() if ip == nil || ip.IsLoopback() { continue } return ip.String(), nil } return "", fmt.Errorf("no non-loopback IPv4 address found") }
Передать hostname, localIP, targetHost в конструктор seclog.Logger (сигнатура уже обновлена в writer.go). Logger автоматически подставляет actor/target в каждое событие без дублирования в каждом вызове WriteEvent. Документированные ограничения: - source_hostname (группа 9): rDNS-резолюция не реализована — поле всегда будет "UNKNOWN". Добавление rDNS с timeout увеличивает latency каждого запроса; рекомендуется реализовать как отдельный фоновый кэш. Задокументировать как известное ограничение. - subject_name (группа 5): в модели API-key аутентификации имена пользователей отсутствуют. Поле заполняется пустой строкой "". Это задокументированное отклонение от требования: требование подразумевает именованных пользователей, REST-прокси использует безымянные ключи организации.
4.3 Создать pkg/seclog/event.go — тип SecurityEvent с 18 обязательными полями
Как решает проблему: типизированная структура на уровне компилятора гарантирует, что ни одно событие не будет записано без обязательных полей. Все 11 групп требования покрыты явными полями структуры — при конструировании SecurityEvent невозможно забыть поле незаметно. Что делать: Создать rest-proxy/pkg/seclog/event.go:
package seclog import ( "log/slog" "time" ) // SecurityEvent — запись события безопасности. // Покрывает все 11 групп обязательных атрибутов. type SecurityEvent struct { // Группа 1: название и идентификатор события EventID string `json:"event_id"` // "AUTH-001", "CONFIG-001" и т.д. EventName string `json:"event_name"` // "USER_LOGIN_SUCCESS" // Группа 2: информация об операции OperationType string `json:"operation_type"` // LOGIN, CREATE, READ, UPDATE, DELETE, EXECUTE OperationDetail string `json:"operation_detail"` // "POST /registry/set channel=mcc" // Группа 3: точное время EventTimestamp time.Time `json:"event_timestamp"` // сериализуется как ISO 8601 // Группы 4–5: объект и субъект операции + идентификатор пользователя ObjectType string `json:"object_type"` // "API_ENDPOINT", "REGISTRY", "SERVICE" ObjectID string `json:"object_id"` // путь запроса или ID ресурса SubjectName string `json:"subject_name"` // имя субъекта ("" допускается — нет имён пользователей в API-key модели) SubjectID string `json:"subject_id"` // orgID из env или "ANONYMOUS" // Группа 6: идентификатор сессии SessionID string `json:"session_id"` // SF-App-ID заголовок или "UNKNOWN" для stateless API // Группа 7–8: статус и причина неуспешности OperationStatus string `json:"operation_status"` // "SUCCESS" | "FAILURE" FailureReason *string `json:"failure_reason,omitempty"` // только при FAILURE // Группа 9: источник информации о событии SourceIP string `json:"source_ip"` // реальный IP клиента (с учётом X-Forwarded-For) SourceHostname string `json:"source_hostname"` // rDNS или "UNKNOWN" // Группа 10: хост, выполнивший операцию (сам сервис rest-proxy) ActorIP string `json:"actor_ip"` // локальный IP сервиса ActorHostname string `json:"actor_hostname"` // hostname сервиса // Группа 11: хост, на котором зафиксирована операция (Fabric Gateway) TargetIP string `json:"target_ip"` // IP Fabric Gateway TargetHostname string `json:"target_hostname"` // hostname Fabric Gateway } func (e SecurityEvent) toAttrs() []slog.Attr { attrs := []slog.Attr{ slog.String("event_id", e.EventID), slog.String("event_name", e.EventName), slog.String("operation_type", e.OperationType), slog.String("operation_detail", e.OperationDetail), slog.Time("event_timestamp", e.EventTimestamp), slog.String("object_type", e.ObjectType), slog.String("object_id", e.ObjectID), slog.String("subject_name", e.SubjectName), slog.String("subject_id", e.SubjectID), slog.String("session_id", e.SessionID), slog.String("operation_status", e.OperationStatus), slog.String("source_ip", e.SourceIP), slog.String("source_hostname", e.SourceHostname), slog.String("actor_ip", e.ActorIP), slog.String("actor_hostname", e.ActorHostname), slog.String("target_ip", e.TargetIP), slog.String("target_hostname", e.TargetHostname), } if e.FailureReason != nil { attrs = append(attrs, slog.String("failure_reason", *e.FailureReason)) } return attrs }
4.4 Создать pkg/seclog/ip.go — корректное извлечение source_ip
Как решает проблему: устраняет частичную реализацию группы 9 (IP источника). c.RealIP() без TrustedProxies возвращает IP балансировщика нагрузки, а не атакующего. Требование говорит «IP адрес источника информации о событии» — это должен быть IP реального клиента, а не инфраструктурного компонента. Что делать: Создать rest-proxy/pkg/seclog/ip.go:
package seclog import ( "fmt" "net" "net/http" "os" "strings" ) // GetRealIP возвращает реальный IP клиента из X-Forwarded-For, // исключая доверенные прокси-подсети. // // Алгоритм: итерация СПРАВА НАЛЕВО по списку IP в заголовке. // X-Forwarded-For: <client>, <proxy1>, <proxy2> ← каждый прокси дописывает справа. // Итерация слева направо уязвима к IP spoofing: атакующий может добавить произвольный // IP в начало заголовка до того, как запрос попадёт к доверенному прокси. // При итерации справа налево мы пропускаем доверенные прокси и останавливаемся // на первом недоверенном адресе — это и есть реальный клиент. func GetRealIP(r *http.Request, trustedProxies []net.IPNet) string { xfwdFor := r.Header.Get("X-Forwarded-For") if xfwdFor == "" { return extractIP(r.RemoteAddr) } parts := strings.Split(xfwdFor, ",") for i := len(parts) - 1; i >= 0; i-- { candidate := strings.TrimSpace(parts[i]) ip := net.ParseIP(candidate) if ip == nil { continue } if !isTrusted(ip, trustedProxies) { return candidate } } return extractIP(r.RemoteAddr) } func isTrusted(ip net.IP, proxies []net.IPNet) bool { for _, subnet := range proxies { if subnet.Contains(ip) { return true } } return false } func extractIP(addr string) string { host, _, err := net.SplitHostPort(addr) if err != nil { return addr } return host } // ParseTrustedProxies парсит список CIDR из переменной окружения TRUSTED_PROXIES. // Пример: TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 func ParseTrustedProxies() []net.IPNet { raw := os.Getenv("TRUSTED_PROXIES") if raw == "" { return nil } var result []net.IPNet for _, cidr := range strings.Split(raw, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } _, network, err := net.ParseCIDR(cidr) if err == nil { result = append(result, *network) } } return result }
Добавить переменную в .env и docker-compose.yaml:
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
4.5 Создать pkg/seclog/writer.go — структурированная запись с корректным timestamp
Как решает проблему: устраняет проблему с форматом времени (группа 3 требования). logrus.TextFormatter выводит время как строку в текстовой записи — это не «точное время» в машиночитаемом виде. slog.NewJSONHandler с ReplaceAttr подавляет автоматическое поле "time" slog — временная метка события уже присутствует в event_timestamp (ISO 8601). Дублирующееся поле "time" в JSON-записи затрудняет парсинг SIEM-правилами. Что делать: Создать rest-proxy/pkg/seclog/writer.go:
package seclog import ( "context" "fmt" "io" "log/slog" "os" "sync/atomic" "time" ) type Logger struct { primary *slog.Logger fallback *slog.Logger droppedCount atomic.Int64 actorIP string actorHostname string targetIP string targetHostname string } func NewLogger(w io.Writer, actorIP, actorHostname, targetIP, targetHostname string) *Logger { handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ Level: slog.LevelInfo, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey { return slog.Attr{} // подавляем авто-поле slog "time" — timestamp уже в event_timestamp } return a }, }) return &Logger{ primary: slog.New(handler), fallback: slog.New(slog.NewJSONHandler(os.Stderr, nil)), actorIP: actorIP, actorHostname: actorHostname, targetIP: targetIP, targetHostname: targetHostname, } } func (l *Logger) WriteEvent(e SecurityEvent) { // Go struct literal допускает нулевые значения — `WriteEvent(SecurityEvent{})` компилируется. // Минимальная проверка предотвращает запись бессмысленных событий без EventID/статуса. if e.EventID == "" || e.OperationStatus == "" { l.droppedCount.Add(1) l.tryWrite(l.fallback, []slog.Attr{ //nolint:errcheck slog.String("error", "WriteEvent: EventID and OperationStatus are required"), slog.String("event_id_received", e.EventID), }) return } // Заполняем actor/target из конфига логера, если не заданы в событии явно. // Семантика: пустая строка = "не задано разработчиком", заполняем из конфига. // Если конфиг тоже пустой — поле остаётся пустым (допустимо для dev-окружения). if e.ActorIP == "" { e.ActorIP = l.actorIP } if e.ActorHostname == "" { e.ActorHostname = l.actorHostname } if e.TargetIP == "" { e.TargetIP = l.targetIP } if e.TargetHostname == "" { e.TargetHostname = l.targetHostname } // SessionID — обязательное поле (ГОСТ 59548-2022, группа 6). // Для stateless API-key аутентификации сессии нет; подставляем "UNKNOWN". if e.SessionID == "" { e.SessionID = "UNKNOWN" } // SourceHostname — rDNS недоступен без сетевых вызовов; подставляем "UNKNOWN". if e.SourceHostname == "" { e.SourceHostname = "UNKNOWN" } attrs := e.toAttrs() if err := l.tryWrite(l.primary, attrs); err != nil { l.droppedCount.Add(1) // Копируем слайс перед append — иначе конкурентные вызовы WriteEvent // разделят backing array и затрут данные друг друга. fallbackAttrs := make([]slog.Attr, len(attrs), len(attrs)+1) copy(fallbackAttrs, attrs) fallbackAttrs = append(fallbackAttrs, slog.String("fallback_reason", err.Error())) l.tryWrite(l.fallback, fallbackAttrs) //nolint:errcheck } } func (l *Logger) tryWrite(logger *slog.Logger, attrs []slog.Attr) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("logger panic: %v", r) } }() logger.LogAttrs(context.Background(), slog.LevelInfo, "security_event", attrs...) return nil } func (l *Logger) DroppedCount() int64 { return l.droppedCount.Load() }
5. Хранение событий безопасности
5.1 Хранение событий безопасности
Требование
Запись событий безопасности должна осуществляться в базы данных (реляционные или NoSQL) или в структурированные текстовые файлы одного из следующих форматов: KeyValue, JSON, XML, CSV, CEF, LEEF, Windows Event Log.
Описание проблемы
Текущий формат вывода — logrus.TextFormatter (time="..." level=warning msg="..."). Он не является ни одним из семи допустимых форматов: не JSON, не XML, не CSV, не CEF, не LEEF, не KeyValue, не Windows Event Log. Единственная запись безопасности (auth/auth.go:35) выводится в stdout в текстовом виде:
time="2026-05-19T14:23:45Z" level=warning msg="unauthorized request" remote_addr="192.168.1.100" service=auth
Это нарушает требование по формату хранения. Почему JSON: из семи допустимых форматов JSON обеспечивает наилучшую совместимость с Go-экосистемой (slog.NewJSONHandler — stdlib), наименьшую стоимость реализации и нативную поддержку в SIEM-системах (Elastic, Splunk, Graylog). CEF и LEEF требуют кастомного форматтера; XML — избыточен; CSV — не поддерживает вложенные структуры; KeyValue — нет стандартного парсера; Windows Event Log — недоступен на Linux.
Контекст кода
Инициализация без форматтера (main.go:15–16):
log := logrus.New() // SetFormatter не вызывается → TextFormatter по умолчанию
5.2 Перевести хранилище событий на JSON Lines
Как решает проблему: заменяет TextFormatter на slog.NewJSONHandler, который записывает каждое событие как отдельный JSON-объект на одной строке (JSON Lines / NDJSON). Это соответствует допустимому формату JSON по требованию. Вывод в файл обеспечивает персистентность независимо от жизненного цикла контейнера. Fallback на os.Stderr гарантирует, что при недоступности файла события не теряются. Что делать: В main.go инициализировать файловое хранилище перед стартом сервера:
logPath := os.Getenv("SEC_LOG_PATH") if logPath == "" { logPath = "/var/log/contracteum/security.log" } securityLogFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) if err != nil { log.Warnf("cannot open security log file, falling back to stderr: %v", err) securityLogFile = os.Stderr } // securityLogFile.Close() вызывается в main() последовательно ПОСЛЕ apiService.Start(): // apiService.Start(isDebugEnabled) // блокируется; пишет AUDSYS-002 до возврата // securityLogFile.Close() // Start() уже вернулся — race condition исключён // НЕ использовать defer: Start() не вызывает os.Exit, поэтому defer выполнится корректно, // но явный вызов лучше документирует намерение и порядок операций. // Fallback управляется внутри seclog.Logger — не нужно передавать os.Stderr снаружи. // NewLogger сам настраивает резервный канал на os.Stderr при сбое записи в primary. secLogger := seclog.NewLogger(securityLogFile, actorIP, actorHostname, targetIP, targetHostname)
В Dockerfile добавить создание директории:
RUN mkdir -p /var/log/contracteum && \ chown nobody:nobody /var/log/contracteum && \ chmod 750 /var/log/contracteum # Права директории 750: владелец читает+пишет, группа читает, остальные — нет. # Права файла 0640 (в os.OpenFile выше): владелец пишет, группа читает, остальные — нет. # 0644 оставляет журнал безопасности читаемым для всех пользователей ОС — недопустимо. # ⚠️ Условие согласования для разработчиков: `nobody:nobody` должен совпадать с USER в Dockerfile. # Если приложение запускается под другим пользователем (USER appuser), заменить # `chown nobody:nobody` на `chown appuser:appuser`. Несовпадение пользователя приведёт # к ошибке доступа при открытии файла журнала (os.OpenFile вернёт EACCES).
В docker-compose.yaml добавить volume для персистентного хранения:
services: rest-proxy: volumes: - security-logs:/var/log/contracteum volumes: security-logs: driver: local
Добавить переменную SEC_LOG_PATH в .env и docker-compose.yaml:
SEC_LOG_PATH=/var/log/contracteum/security.log
Ротация файла (рекомендуется): без ротации файл растёт неограниченно. При заполнении диска os.OpenFile с O_WRONLY завершится ошибкой — новые события безопасности начнут теряться. Fallback на stderr не поможет при полностью заполненном диске. Рекомендуется добавить gopkg.in/lumberjack.v2 вместо os.OpenFile:
import "gopkg.in/lumberjack.v2" rotatingWriter := &lumberjack.Logger{ Filename: logPath, MaxSize: 256, // МБ MaxBackups: 30, MaxAge: 90, // дней Compress: true, } // Поле FileMode отсутствует в публичном API gopkg.in/lumberjack.v2 (v2.2.1). // lumberjack создаёт новые файлы с правами 0600 по умолчанию. // Для получения 0640 предварительно создать файл через os.OpenFile: // f, _ := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640) // f.Close() // После этого lumberjack откроет существующий файл, не меняя его права. // Либо задать umask процесса: в Dockerfile добавить RUN umask 0026. secLogger := seclog.NewLogger(rotatingWriter, actorIP, actorHostname, targetIP, targetHostname)
Результирующий вид записи (JSON Lines):
{"time":"2026-05-19T14:23:45.123456789Z","level":"INFO","msg":"security_event","event_id":"AUTH-002","event_name":"USER_AUTH_FAILURE","operation_type":"LOGIN","event_timestamp":"2026-05-19T14:23:45.123456789Z","operation_status":"FAILURE","source_ip":"192.168.1.100","source_hostname":"UNKNOWN"}