From ae1a683adab97462ab5f1339513e69743d7aeb97 Mon Sep 17 00:00:00 2001 From: emelinda Date: Mon, 2 Nov 2026 00:33:20 +0300 Subject: [PATCH] **Add HelmRelease `access-collector` for processing ingress-gateway access logs in `security-audit` namespace with Kafka and Vault integration** --- .../istio-pilot/uralkal/istio-pilot.yaml | 31 +++ infrastructure/security-audit/README.md | 201 +++++++++++++++++- .../base/helmrelease-access-collector.yaml | 193 +++++++++++++++++ .../base/helmrelease-collector.yaml | 3 + .../security-audit/base/kustomization.yaml | 2 + .../security-audit/base/namespace.yaml | 4 +- .../base/networkpolicy-access-collector.yaml | 33 +++ .../security-audit/mesh/kustomization.yaml | 11 + .../mesh/strip-audit-headers.yaml | 32 +++ .../security-audit/mesh/telemetry.yaml | 17 ++ .../uralkal/access-collector.yaml | 17 ++ .../security-audit/uralkal/kustomization.yaml | 25 +++ 12 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 infrastructure/security-audit/base/helmrelease-access-collector.yaml create mode 100644 infrastructure/security-audit/base/networkpolicy-access-collector.yaml create mode 100644 infrastructure/security-audit/mesh/kustomization.yaml create mode 100644 infrastructure/security-audit/mesh/strip-audit-headers.yaml create mode 100644 infrastructure/security-audit/mesh/telemetry.yaml create mode 100644 infrastructure/security-audit/uralkal/access-collector.yaml diff --git a/infrastructure/istio-pilot/uralkal/istio-pilot.yaml b/infrastructure/istio-pilot/uralkal/istio-pilot.yaml index a7d0773..f2a0646 100644 --- a/infrastructure/istio-pilot/uralkal/istio-pilot.yaml +++ b/infrastructure/istio-pilot/uralkal/istio-pilot.yaml @@ -13,3 +13,34 @@ spec: global: imagePullSecrets: - regcred + # Провайдер журнала доступа для security-audit (access-collector). Сам по себе ничего не включает: + # работает только для workload'ов, на которые ссылается Telemetry (infrastructure/security-audit/mesh). + meshConfig: + extensionProviders: + - name: audit-access + envoyOtelAls: + service: access-collector.security-audit.svc.cluster.local + port: 4317 + logFormat: + labels: + sarex_kind: "mesh_access" + start_time: "%START_TIME%" + request_id: "%REQ(X-REQUEST-ID)%" + trace_id: "%TRACE_ID%" + authority: "%REQ(:AUTHORITY)%" + method: "%REQ(:METHOD)%" + path: "%REQ_WITHOUT_QUERY(:PATH)%" + protocol: "%PROTOCOL%" + response_code: "%RESPONSE_CODE%" + response_flags: "%RESPONSE_FLAGS%" + duration_ms: "%DURATION%" + bytes_in: "%BYTES_RECEIVED%" + bytes_out: "%BYTES_SENT%" + x_forwarded_for: "%REQ(X-FORWARDED-FOR)%" + downstream_ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" + user_sub: "%REQ(X-SAREX-AUDIT-SUB)%" + user_org: "%REQ(X-SAREX-AUDIT-ORG)%" + user_agent: "%REQ(USER-AGENT)%" + route: "%ROUTE_NAME%" + upstream_cluster: "%UPSTREAM_CLUSTER%" + # Authorization, Cookie, Set-Cookie и query-строка не логируются (п.23) diff --git a/infrastructure/security-audit/README.md b/infrastructure/security-audit/README.md index 56a101d..0ff8a4b 100644 --- a/infrastructure/security-audit/README.md +++ b/infrastructure/security-audit/README.md @@ -1,16 +1,28 @@ # security-audit -Сбор журнала аудита kube-apiserver с мастеров в Kafka-топик `security.k8s-audit`. -Одно сообщение — одно событие аудита в JSON (`encoding: raw`, без OTLP-обёртки), обогащённое полями `sarex_*`. +Поток ИБ в Kafka, два источника: + +| Источник | Коллектор | Топик | +|---|---|---| +| журнал аудита kube-apiserver (мастера) | DaemonSet `audit-collector` | `security.k8s-audit` | +| журнал доступа ingress-шлюза Istio | Deployment `access-collector` | `security.mesh-access` | + +Одно сообщение — одна запись в JSON (`encoding: raw`, без OTLP-обёртки), обогащённая полями `sarex_*`. Дальнейшие этапы (processor → MinIO/Parquet) — отдельными компонентами. С observability-коллектором (`infrastructure/opentelemetry-collector`) не связан: отдельный релиз, namespace и конфиг. +| Каталог | Что | Namespace | +|---|---|---| +| `base/` | оба коллектора, NetworkPolicy, Namespace | `security-audit` | +| `mesh/` | EnvoyFilter и Telemetry шлюза; без `namespace` в kustomization, селектор шлюза — заглушка | `istio-system` | +| `uralkal/` | оверлей: `base` + `mesh`, селектор шлюза, Kafka, `dependsOn` | — | + | Оверлей | Статус | |---|---| | `uralkal` | манифесты готовы, **в `clusters/uralkal` не подключён** | -## Что делает коллектор +## Журнал аудита kube-apiserver DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/control-plane`. Он читает `/var/log/audit/kube-apiserver-audit*.log` (текущий и ротированные файлы), позиции хранит в `/var/lib/otelcol-audit` @@ -93,7 +105,8 @@ DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/contr `ca_crt` — содержимое `ca.crt` из Secret `kafka/kafka-kafka-contour-tls` (CA генерирует чарт `kafka-contour`, срок 365 дней; при перевыпуске обновить и здесь); - политика `read` на `secrets/data/security-audit/kafka`; - - роль `security-audit` в `auth/kubernetes`: bound SA `audit-collector`, namespace `security-audit`, политика выше. + - роль `security-audit` в `auth/kubernetes`: bound SA `audit-collector` **и** `access-collector`, namespace + `security-audit`, политика выше. Заводится в `infra/terraform`, ветка `contour`, модуль `vault-platform-secrets` (роли `vault_kubernetes_auth_backend_role`, KV-секреты). @@ -111,6 +124,10 @@ DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/contr `dependsOn` в оверлее ждёт HelmRelease `kafka/kafka` и `vault/vault`. +Порядок: сначала должен раскататься провайдер `audit-access` в istiod (`infrastructure/istio-pilot/uralkal/istio-pilot.yaml`, +применяется сразу, как только попадёт в master: кластер его уже подключает). Telemetry из `mesh/` ссылается на этот провайдер по имени. Проверка: +`kubectl -n istio-system get cm istio -o jsonpath='{.data.mesh}' | grep -A2 audit-access`. + ## Проверка после подключения - DaemonSet `security-audit/audit-collector-agent` только на мастерах, все поды `Ready`: @@ -129,6 +146,167 @@ DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/contr коллектор не маскирует: OTTL плохо работает с массивами (`spec.containers[].env[]`). Маскирование будет в processor'е. До его готовности не повышать уровень для workload-ресурсов выше `Request` в контурах, где пароли могут быть в env. +## Журнал доступа шлюза + +Каждый внешний HTTP-запрос через ingress-шлюз (`istio-system`, `istio: ingressgateway`): кто, что, когда, результат, +IP, `x-request-id`, `trace_id`. Поток ИБ отдельно от прикладных журналов (п.20); IP источника для событий приложений +находится по `request_id` (п.15). + +``` +ingress-шлюз (Envoy) + └─ Telemetry gateway-audit-access ─► провайдер audit-access (envoyOtelAls, meshConfig istiod) + └─ OTLP gRPC, mTLS ─► access-collector.security-audit:4317 (2 реплики, sidecar Istio) + └─ transform/access ─► Kafka security.mesh-access ─► processor (infra/k8s-audit, отдельная задача) +``` + +### Поля записи + +| Поле | Источник Envoy | Примечание | +|---|---|---| +| `sarex_kind` | — | всегда `mesh_access` | +| `start_time` | `%START_TIME%` | | +| `request_id` | `%REQ(X-REQUEST-ID)%` | ключ связи с журналами приложений (п.15) | +| `trace_id` | `%TRACE_ID%` | пусто, пока в mesh выключен трейсинг | +| `authority`, `method`, `protocol` | `:authority`, `:method`, `%PROTOCOL%` | | +| `path` | `%REQ_WITHOUT_QUERY(:PATH)%` | без query; маскирование — ниже | +| `response_code`, `response_flags` | `%RESPONSE_CODE%`, `%RESPONSE_FLAGS%` | `0` — ответа не было | +| `duration_ms`, `bytes_in`, `bytes_out` | `%DURATION%`, `%BYTES_RECEIVED%`, `%BYTES_SENT%` | | +| `x_forwarded_for`, `downstream_ip` | `X-Forwarded-For`, `%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%` | за NodePort — см. открытые вопросы | +| `user_sub`, `user_org` | `x-sarex-audit-sub`, `x-sarex-audit-org` | заполняет RequestAuthentication из JWT; **пока её нет — пусто** | +| `user_agent`, `route`, `upstream_cluster` | `User-Agent`, `%ROUTE_NAME%`, `%UPSTREAM_CLUSTER%` | | +| `sarex_contour` | env `CONTOUR` | | +| `sarex_action` | по `method` | `read`: GET/HEAD/OPTIONS; `mutate`: POST/PUT/PATCH/DELETE | +| `sarex_outcome` | по `response_code` | `applied` 2xx–3xx, `denied_authn` 401, `denied_authz` 403, `invalid` прочие 4xx (включая 404), `error` 5xx и `0` | +| `sarex_actor_type` | по `user_sub` | `human`, если есть, иначе `anonymous` | + +**Не логируется (п.23):** +- `Authorization`, `Cookie`, `Set-Cookie` — это сами учётные данные: журнал с ними позволяет войти от имени пользователя; +- query-строка — в ней бывают токены публичных ссылок, `token=`, подписи S3 (`X-Amz-Signature`). + +Маскирование в пути: +- сегмент после `token|secret|key|signature/` → `***`; +- JWT-подобные строки → `***jwt***`. + +Защита от подмены: EnvoyFilter `strip-audit-headers` первым HTTP-фильтром удаляет входящие `x-sarex-audit-sub/org`. +Без него клиент мог бы записать в журнал любого пользователя. + +### Пользователь в журнале: RequestAuthentication — не создана (блокер) + +`user_sub`/`user_org` должна заполнять `RequestAuthentication` на шлюзе (`outputClaimToHeaders`). Но она отклоняет +**любой** запрос с JWT издателя, которого нет в `jwtRules` (`Jwt issuer is not configured`, 401), на всех хостах шлюза. +А издатели в Уралкалии подтверждаются по репозиторию не все: + +| Издатель | Заголовок | `issuer` / JWKS | Статус | +|---|---|---|---| +| Zitadel | `Authorization: Bearer`, `Identity` | `https://login.sarex.local.uralkali.com` (`ExternalDomain` + `ExternalSecure: true` в `infrastructure/zitadel/uralkal`; `ExternalPort` не задан — проверить, что в `/.well-known/openid-configuration` нет `:8080`) | issuer — `TODO(verify)`; префикс заголовка `Identity` не подтверждён | +| django (SimpleJWT, RS512) | `Authorization: Bearer` (режим `sarex`) | в коде `SIMPLE_JWT_ISSUER` по умолчанию `default_issuer` (`apps/django/base/django-configmap.yaml`), в `CONFIGURATION.md` — `django`; в `uralkal` не задан. Ключ — `secrets/data/vault/common/rsa_keys`, в репозитории нет | **не подтверждён** | +| Camunda Keycloak | `Authorization: Bearer` на `camunda-*` | `https://camunda-keycloak.sarex.local.uralkali.com/auth/realms/camunda-platform` (`infrastructure/camunda/uralkal`), JWKS `…/protocol/openid-connect/certs` | подтверждён | +| document-link | `Authorization: Bearer` (зашитый/сервисный JWT) | неизвестен | **не подтверждён** | + +Пока таблица не закрыта, журнал пишется без пользователя (`sarex_actor_type=anonymous`). Пользователя для события +можно найти по `request_id` в журналах приложений. + +JWKS Zitadel — выбранный вариант, когда дойдёт до включения: +- **inline `jwks` — нет:** Zitadel ротирует ключи подписи по умолчанию раз в часы (`KeyConfig.PrivateKeyLifetime` 6h), + inline-набор устареет за день, и шлюз начнёт отдавать 401 всем. +- **`jwksUri` на внешний адрес, загрузка istiod — только если подтверждено:** istiod резолвит + `login.sarex.local.uralkali.com` и доверяет CA сертификата `uralkal-sarex-tls`. В istiod нет CA заказчика, и + отключать проверку (`JWKS_RESOLVER_INSECURE_SKIP_VERIFY`) нельзя. +- **Рекомендуется:** `PILOT_JWT_ENABLE_REMOTE_JWKS=envoy` (env istiod) и `jwksUri: http://login.sarex.local.uralkali.com:8080/oauth/v2/keys` + плюс `ServiceEntry` хоста `login.sarex.local.uralkali.com:8080` (HTTP, `resolution: DNS`, endpoint + `zitadel-idp-contour.zitadel.svc.cluster.local`). Ключи забирает Envoy шлюза через кластер mesh, без внешнего DNS и CA. + `Host` остаётся внешним доменом инстанса, его Zitadel узнаёт. Прямой `http://zitadel-idp-contour.zitadel.svc:8080/...` + Zitadel отклонит: инстанс определяется по `Host`. + +Черновик (не подключён; включать после закрытия таблицы и согласования 401 с владельцами приложений): + +```yaml +apiVersion: security.istio.io/v1 +kind: RequestAuthentication +metadata: {name: audit-jwt, namespace: istio-system} +spec: + selector: {matchLabels: {istio: ingressgateway}} + jwtRules: + - issuer: https://login.sarex.local.uralkali.com # TODO(verify) + jwksUri: http://login.sarex.local.uralkali.com:8080/oauth/v2/keys + forwardOriginalToken: true + fromHeaders: + - {name: Authorization, prefix: "Bearer "} + - {name: Identity, prefix: "Bearer "} # TODO(verify): префикс + outputClaimToHeaders: + - {header: x-sarex-audit-sub, claim: sub} + - {header: x-sarex-audit-org, claim: "urn:zitadel:iam:user:resourceowner:id"} # TODO(verify): есть ли claim в access token + - issuer: https://camunda-keycloak.sarex.local.uralkali.com/auth/realms/camunda-platform + jwksUri: http://camunda-keycloak.camunda.svc.cluster.local/auth/realms/camunda-platform/protocol/openid-connect/certs # TODO(verify) + forwardOriginalToken: true + # - issuer: # блокер: issuer и публичный ключ +``` + +### Предусловия подключения + +- Топик `security.mesh-access`: 3 партиции, `retention.ms=259200000`, `max.message.bytes=1048576`, RF по контуру — + тем же путём, что `security.k8s-audit` (terraform или команды выше). Пользователь — тот же `security-audit`, ACL не нужны. +- Роль Vault `security-audit`: в bound SA добавлен `access-collector` (см. предусловия выше). +- Провайдер `audit-access` раскатан в istiod (см. «Подключение»). + +### Sidecar и Vault + +- Namespace `security-audit` без `istio-injection`: sidecar получают только поды с меткой `sidecar.istio.io/inject=true` + (webhook `object.sidecar-injector.istio.io`). Это `access-collector`. У `audit-collector` метка `"false"`. + Метка `istio-injection: disabled` отключила бы инъекцию совсем, даже по метке пода. +- `PeerAuthentication` в репозитории нет, поэтому режим PERMISSIVE. Шлюз шлёт в коллектор по auto-mTLS (оба в mesh), + коллектор в Kafka — тоже через mTLS (`kafka` в mesh), внутри остаётся `SASL_SSL`. +- Vault Agent (`agent-init-first`, pre-populate) + istio-proxy: по паттерну контура (`apps/bi`, `apps/bim`, `apps/cde`…) + `traffic.sidecar.istio.io/excludeOutboundPorts: "8200"`. Init-агент ходит в Vault мимо iptables-перехвата, + даже если `istio-init` отработал раньше. +- `holdApplicationUntilProxyStarts`: kafka exporter подключается к брокерам на старте и без готового proxy падает. +- `istio-init` требует `NET_ADMIN`/`NET_RAW`: enforce `privileged` это разрешает, профиль `warn: baseline` даст + предупреждения при создании подов — ожидаемо. + +### Проверка после подключения + +- Провайдер и порядок фильтров на шлюзе: + ```bash + POD=$(kubectl -n istio-system get pod -l istio=ingressgateway -o jsonpath='{.items[0].metadata.name}') + istioctl -n istio-system proxy-config cluster $POD | grep access-collector + istioctl -n istio-system proxy-config listener $POD -o json \ + | jq -r '.[].filterChains[].filters[] | select(.name=="envoy.filters.network.http_connection_manager") + | .typedConfig.httpFilters[].name' | uniq + ``` + Первым в списке — `security-audit.strip-audit-headers`, раньше `envoy.filters.http.jwt_authn` (когда появится). + В выводе `listener` есть `envoy.access_loggers.open_telemetry`. +- `curl -H 'x-sarex-audit-sub: spoof' https://sarex.local.uralkali.com/...` → в Kafka `user_sub` пустой. +- Запрос с `?token=canary` → строки `canary` нет в топике. +- После включения RequestAuthentication: + - запрос с валидным токеном → `user_sub` заполнен; + - с невалидным токеном Zitadel → 401 (так работает RequestAuthentication). **Согласовать с владельцами приложений до включения.** +- `kubectl -n security-audit scale deploy/access-collector --replicas=0` → трафик шлюза не падает (ALS асинхронный). + Потери видны в статистике Envoy: `kubectl -n istio-system exec $POD -- pilot-agent request GET stats | grep access_logs`. + Вернуть 2 реплики. +- Чтение топика: как для `security.k8s-audit`, `--topic security.mesh-access`. + +### Мониторинг + +- `otelcol_receiver_accepted_log_records{receiver="otlp"}` у `access-collector`: алерт, если 0 за 10 минут при живом + трафике шлюза (`istio_requests_total` на `ingressgateway`). +- `otelcol_exporter_sent_log_records` / `otelcol_exporter_send_failed_log_records` (`exporter="kafka/access"`), + `otelcol_exporter_queue_size` против `otelcol_exporter_queue_capacity`, `otelcol_exporter_enqueue_failed_log_records`. +- Метрики коллектора отдаются на `:8888` пода. Prometheus в Уралкалии по репозиторию не найден — сбор метрик отдельно. + +### Открытые вопросы + +- **IP клиента (п.15).** Шлюз — одна реплика на control-plane за Service `NodePort` (30080/30443) без + `externalTrafficPolicy`, т. е. `Cluster`. kube-proxy делает SNAT, и в `downstream_ip` попадает IP узла, а не клиента. + Нужно выяснить, есть ли перед NodePort балансировщик заказчика и передаёт ли он `X-Forwarded-For`. Если да — + `meshConfig.defaultConfig.gatewayTopology.numTrustedProxies` (или аннотация `proxy.istio.io/config` на шлюзе) по числу + прокси, иначе Envoy не доверяет XFF. Если балансировщика нет — `externalTrafficPolicy: Local` и DNS/LB только на + узел шлюза. Ничего из этого не меняли. +- **Статика** (`*/static/*`, фронтенды) пишется в журнал. Исключать ли — решение ИБ (фильтр в `mesh/telemetry.yaml`). +- **Запросы между сервисами** (sidecar'ы приложений) — отдельный Telemetry с фильтром, не в этой задаче. +- **Очередь в памяти:** `queue_size: 50000` считается в запросах (батчах до 2000 записей), а не в записях. При долгой + недоступности Kafka упрётся в `memory_limiter`/лимит 512Mi раньше, чем в `queue_size`. Записи сверх этого теряются, + шлюз не блокируется. + ## Проверить перед подключением Сверено по репозиторию и чартам 01.11.2026, доступа к кластеру не было. @@ -144,10 +322,25 @@ DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/contr | Узлы мастеров | метка и taint `node-role.kubernetes.io/control-plane` | так же сажается `istio-gateway/uralkal` | | Рендер чарта | нет `hostPort`, Service, ClusterRole; nodeSelector/tolerations/securityContext как заданы | `helm template` 0.117.1 с values из собранного HelmRelease | | Логика `transform` | 5 тестовых событий классифицированы верно, `token=***`, `managedFields` вырезаны | запуск коллектора локально в том же образе с `file`-экспортёром вместо kafka | +| **Журнал доступа шлюза** (02.11.2026) | | | +| Шлюз | HelmRelease `istio-system/ingressgateway`, метка `istio: ingressgateway`, 1 реплика на control-plane, `NodePort` 30080/30443, `externalTrafficPolicy` не задан (`Cluster`) | `helm template` `istio-gateway-contour` 1.24.0 с values `uralkal` | +| istiod | `istiod-contour` 1.24.0, ревизий нет; кроме `extensionProviders` mesh config не изменился | `helm template` до и после правки, diff только в ConfigMap `istio` | +| Инъекция sidecar | ns `istio-injection=enabled` или ns без метки + метка пода `sidecar.istio.io/inject=true`; `disabled` блокирует оба | селекторы MutatingWebhookConfiguration из рендера `istiod-contour` | +| mTLS | `PeerAuthentication` в репозитории нет → PERMISSIVE | поиск по `infrastructure/`, `clusters/uralkal`, чарту `istio-config-contour` | +| Vault + sidecar | `excludeOutboundPorts: "8200"` рядом с `agent-init-first` | `apps/bi`, `apps/bim`, `apps/cde/base` | +| Образ access-collector | тот же; `otlp` receiver, `transform`, `kafka` exporter есть | `otelcol-contrib validate` — OK | +| Логика `transform/access` | 7 записей в формате Envoy ALS: `-` → пусто, `token/***`, `***jwt***`, классы верные | локальный коллектор, OTLP/HTTP → `file`-экспортёр | +| Схемы Istio | EnvoyFilter (`v1alpha3`) и Telemetry (`v1`) проходят CRD-схемы `istio-base-contour` 1.24.0 | свой проверщик по `openAPIV3Schema`; `istioctl`/`kubeconform` на машине нет. Конфиг Lua (`patch.value`) схемой не проверяется | Остаётся открытым: - `TODO(verify)` **nodelocaldns** (`169.254.25.10`) в NetworkPolicy: в репозитории не подтверждается. При дефолте kubespray включён, если нет — правило безвредно. +- **RequestAuthentication** не создана: не подтверждены издатели django и document-link, `issuer` Zitadel (`TODO(verify)`) — + см. «Пользователь в журнале». Пока `user_sub` пустой. +- **IP клиента за NodePort** — см. «Открытые вопросы» журнала доступа. +- `set(body, attributes)` + `encoding: raw` даёт JSON-объект (проверено локально через `file`-экспортёр, raw-маршалер + сериализует map в JSON) — подтвердить на стенде чтением топика. Там же проверить, что Envoy шлёт labels строками + (сравнения `response_code == "401"`). - Некритичные предупреждения 0.121.0: `service::telemetry::metrics::address` устарел (так же во всех коллекторах репо); OTTL-пути без префикса `log.` (контекст выводится автоматически). diff --git a/infrastructure/security-audit/base/helmrelease-access-collector.yaml b/infrastructure/security-audit/base/helmrelease-access-collector.yaml new file mode 100644 index 0000000..0483f39 --- /dev/null +++ b/infrastructure/security-audit/base/helmrelease-access-collector.yaml @@ -0,0 +1,193 @@ +# Журнал доступа ingress-шлюза Istio: envoyOtelAls (провайдер audit-access в meshConfig istiod) → OTLP gRPC → Kafka. +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: access-collector + namespace: security-audit +spec: + interval: 10m + timeout: 10m + chart: + spec: + chart: opentelemetry-collector-prod + version: "0.117.1" + sourceRef: + kind: HelmRepository + name: yc-oci-charts + namespace: flux-system + interval: 10m + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + mode: deployment + replicaCount: 2 + fullnameOverride: access-collector + + # Тот же образ, что у audit-collector: otlp receiver, transform, kafka exporter есть в сборке. + image: + repository: cr.yandex/crp3ccidau046kdj8g9q/opentelemetry-collector/opentelemetry-collector-contrib + tag: "0.121.0-amd64" + command: + name: otelcol-contrib + imagePullSecrets: + - name: regcred + + podDisruptionBudget: + enabled: true + minAvailable: 1 + # Мягкая: при одном доступном узле вторая реплика всё равно встанет. + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + security-audit/collector: access + + clusterRole: + create: false + serviceAccount: + create: true + name: access-collector # bound SA роли Vault security-audit (вместе с audit-collector) + presets: + logsCollection: {enabled: false} + hostMetrics: {enabled: false} + kubernetesAttributes: {enabled: false} + kubeletMetrics: {enabled: false} + kubernetesEvents: {enabled: false} + clusterMetrics: {enabled: false} + + ports: + otlp: + enabled: true + containerPort: 4317 + servicePort: 4317 + protocol: TCP + appProtocol: grpc # Istio выбирает протокол по appProtocol + otlp-http: {enabled: false} + jaeger-compact: {enabled: false} + jaeger-thrift: {enabled: false} + jaeger-grpc: {enabled: false} + zipkin: {enabled: false} + metrics: {enabled: false} + service: + enabled: true + + podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: {type: RuntimeDefault} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + privileged: false + capabilities: + drop: [ALL] + + resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {cpu: 1000m, memory: 512Mi} + + podLabels: + security-audit/collector: access # селектор NetworkPolicy и anti-affinity + sidecar.istio.io/inject: "true" # mTLS внутри mesh (п.30); namespace без istio-injection + podAnnotations: + sidecar.istio.io/inject: "true" + # Kafka exporter подключается к брокерам на старте: без готового istio-proxy коллектор упадёт. + proxy.istio.io/config: '{"holdApplicationUntilProxyStarts": true}' + # Паттерн контура для Vault + sidecar (apps/*/base): трафик агента к Vault мимо istio-proxy. + traffic.sidecar.istio.io/excludeOutboundPorts: "8200" + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-init-first: "true" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-requests-cpu: "25m" + vault.hashicorp.com/agent-limits-cpu: "100m" + vault.hashicorp.com/auth-path: auth/kubernetes + vault.hashicorp.com/role: security-audit + # Многострочные шаблоны — см. helmrelease-collector.yaml (toYaml | tpl в чарте). + vault.hashicorp.com/agent-inject-secret-kafka-username: secrets/data/security-audit/kafka + vault.hashicorp.com/agent-inject-template-kafka-username: |- + {{ "{{- with secret \"secrets/data/security-audit/kafka\" -}}" }} + {{ "{{ .Data.data.username }}" }} + {{ "{{- end -}}" }} + vault.hashicorp.com/agent-inject-secret-kafka-password: secrets/data/security-audit/kafka + vault.hashicorp.com/agent-inject-template-kafka-password: |- + {{ "{{- with secret \"secrets/data/security-audit/kafka\" -}}" }} + {{ "{{ .Data.data.password }}" }} + {{ "{{- end -}}" }} + vault.hashicorp.com/agent-inject-secret-kafka-ca.crt: secrets/data/security-audit/kafka + vault.hashicorp.com/agent-inject-template-kafka-ca.crt: |- + {{ "{{- with secret \"secrets/data/security-audit/kafka\" -}}" }} + {{ "{{ .Data.data.ca_crt }}" }} + {{ "{{- end -}}" }} + + # Контур-специфичное (CONTOUR, KAFKA_BROKERS) задаётся в оверлее через extraEnvs. + alternateConfig: + extensions: + health_check: {endpoint: "${env:MY_POD_IP}:13133"} + receivers: + otlp: + protocols: + grpc: + endpoint: "${env:MY_POD_IP}:4317" + max_recv_msg_size_mib: 16 + processors: + memory_limiter: {check_interval: 1s, limit_mib: 400, spike_limit_mib: 100} + transform/access: + error_mode: ignore + log_statements: + - context: log + statements: + # labels из envoyOtelAls приходят атрибутами — делаем из них тело (в Kafka уходит body как JSON) + - set(body, attributes) + - set(body["sarex_contour"], "${env:CONTOUR}") + # Envoy пишет "-" для пустых значений + - set(body["user_sub"], "") where body["user_sub"] == "-" + - set(body["user_org"], "") where body["user_org"] == "-" + - set(body["trace_id"], "") where body["trace_id"] == "-" + # маскирование: токены в самом пути (публичные ссылки и т. п.) + - replace_pattern(body["path"], "(?i)(token|secret|key|signature)/[^/]+", "$$1/***") + - replace_pattern(body["path"], "[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{10,}", "***jwt***") + # классификация + - set(body["sarex_action"], "read") where body["method"] == "GET" or body["method"] == "HEAD" or body["method"] == "OPTIONS" + - set(body["sarex_action"], "mutate") where body["method"] == "POST" or body["method"] == "PUT" or body["method"] == "PATCH" or body["method"] == "DELETE" + - set(body["sarex_outcome"], "applied") where Int(body["response_code"]) >= 200 and Int(body["response_code"]) < 400 + - set(body["sarex_outcome"], "denied_authn") where body["response_code"] == "401" + - set(body["sarex_outcome"], "denied_authz") where body["response_code"] == "403" + - set(body["sarex_outcome"], "invalid") where Int(body["response_code"]) >= 400 and Int(body["response_code"]) < 500 and body["response_code"] != "401" and body["response_code"] != "403" + - set(body["sarex_outcome"], "error") where Int(body["response_code"]) >= 500 or body["response_code"] == "0" + - set(body["sarex_actor_type"], "anonymous") + - set(body["sarex_actor_type"], "human") where body["user_sub"] != "" + batch: {send_batch_size: 1000, send_batch_max_size: 2000, timeout: 2s} + exporters: + kafka/access: + brokers: ["${env:KAFKA_BROKERS}"] + protocol_version: 2.8.0 + topic: security.mesh-access + encoding: raw # в сообщение — только body (JSON записи), без OTLP-обёртки + auth: + sasl: {mechanism: SCRAM-SHA-512, username: "${file:/vault/secrets/kafka-username}", password: "${file:/vault/secrets/kafka-password}"} + tls: {insecure: false, ca_file: /vault/secrets/kafka-ca.crt} + producer: {required_acks: -1, compression: zstd, max_message_bytes: 1048576} + # Очередь в памяти (диска у Deployment нет): при падении пода неотправленные записи теряются. + sending_queue: {enabled: true, queue_size: 50000, num_consumers: 4} + retry_on_failure: {enabled: true, initial_interval: 5s, max_interval: 60s, max_elapsed_time: 0} + service: + extensions: [health_check] + pipelines: + logs/access: + receivers: [otlp] + processors: [memory_limiter, transform/access, batch] + exporters: [kafka/access] + telemetry: + logs: {level: info} + metrics: {address: "${env:MY_POD_IP}:8888"} diff --git a/infrastructure/security-audit/base/helmrelease-collector.yaml b/infrastructure/security-audit/base/helmrelease-collector.yaml index 061b85a..6298027 100644 --- a/infrastructure/security-audit/base/helmrelease-collector.yaml +++ b/infrastructure/security-audit/base/helmrelease-collector.yaml @@ -100,6 +100,9 @@ spec: - {name: audit-log, mountPath: /var/log/audit, readOnly: true} - {name: otelcol-storage, mountPath: /var/lib/otelcol-audit} + # Метка исключает под из webhook'а инъекции Istio ещё до мутации (namespace без istio-injection). + podLabels: + sidecar.istio.io/inject: "false" podAnnotations: sidecar.istio.io/inject: "false" vault.hashicorp.com/agent-inject: "true" diff --git a/infrastructure/security-audit/base/kustomization.yaml b/infrastructure/security-audit/base/kustomization.yaml index acf1053..eafdc13 100644 --- a/infrastructure/security-audit/base/kustomization.yaml +++ b/infrastructure/security-audit/base/kustomization.yaml @@ -4,4 +4,6 @@ namespace: security-audit resources: - namespace.yaml - networkpolicy.yaml + - networkpolicy-access-collector.yaml - helmrelease-collector.yaml + - helmrelease-access-collector.yaml diff --git a/infrastructure/security-audit/base/namespace.yaml b/infrastructure/security-audit/base/namespace.yaml index 5fb139d..543827b 100644 --- a/infrastructure/security-audit/base/namespace.yaml +++ b/infrastructure/security-audit/base/namespace.yaml @@ -7,4 +7,6 @@ metadata: pod-security.kubernetes.io/enforce: privileged pod-security.kubernetes.io/warn: baseline pod-security.kubernetes.io/audit: baseline - istio-injection: disabled + # Без istio-injection: инъекция только по метке пода sidecar.istio.io/inject=true + # (webhook object.sidecar-injector.istio.io). Метка disabled отключила бы и её. + # Sidecar получает access-collector (п.30); audit-collector — нет. diff --git a/infrastructure/security-audit/base/networkpolicy-access-collector.yaml b/infrastructure/security-audit/base/networkpolicy-access-collector.yaml new file mode 100644 index 0000000..64da87e --- /dev/null +++ b/infrastructure/security-audit/base/networkpolicy-access-collector.yaml @@ -0,0 +1,33 @@ +# Дополняет общую audit-collector (DNS, Kafka, Vault, 13133): политики складываются. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: access-collector +spec: + podSelector: + matchLabels: + security-audit/collector: access + policyTypes: [Ingress, Egress] + ingress: + # OTLP gRPC только от ingress-шлюза. Селектор шлюза — заглушка, контур задаёт его патчем оверлея; + # без патча не совпадёт ни с одним подом (закрыто по умолчанию). + - from: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: istio-system} + podSelector: + matchLabels: {security-audit/gateway: unset} + ports: + - {port: 4317, protocol: TCP} + # istio-proxy: 15020 — переписанные probe'ы приложения (kubelet), 15021 — readiness самого sidecar. + - ports: + - {port: 15020, protocol: TCP} + - {port: 15021, protocol: TCP} + egress: + # xDS и сертификаты istio-proxy от istiod. + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: istio-system} + podSelector: + matchLabels: {app: istiod} + ports: + - {port: 15012, protocol: TCP} diff --git a/infrastructure/security-audit/mesh/kustomization.yaml b/infrastructure/security-audit/mesh/kustomization.yaml new file mode 100644 index 0000000..7088944 --- /dev/null +++ b/infrastructure/security-audit/mesh/kustomization.yaml @@ -0,0 +1,11 @@ +# Ресурсы шлюза Istio. Намеренно без поля namespace: base модуля переписал бы его на security-audit, +# а этим ресурсам нужно оставаться в istio-system. Подключается из оверлея контура как resource, +# селектор шлюза (заглушка security-audit/gateway: unset) патчится там. +# +# RequestAuthentication (пользователь в журнале — x-sarex-audit-sub/org) здесь пока нет: не подтверждены все +# издатели JWT на шлюзе, а неизвестный издатель даёт 401. См. README, «Журнал доступа шлюза». +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - strip-audit-headers.yaml + - telemetry.yaml diff --git a/infrastructure/security-audit/mesh/strip-audit-headers.yaml b/infrastructure/security-audit/mesh/strip-audit-headers.yaml new file mode 100644 index 0000000..e23f45f --- /dev/null +++ b/infrastructure/security-audit/mesh/strip-audit-headers.yaml @@ -0,0 +1,32 @@ +# Удаляет входящие x-sarex-audit-*: клиент не должен подставлять себе пользователя в журнал доступа. +# Стоит первым HTTP-фильтром (INSERT_FIRST) — раньше jwt_authn, который заполняет эти заголовки из claims. +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: strip-audit-headers + namespace: istio-system +spec: + workloadSelector: + labels: + security-audit/gateway: unset + configPatches: + - applyTo: HTTP_FILTER + match: + context: GATEWAY + listener: + filterChain: + filter: + name: envoy.filters.network.http_connection_manager + patch: + operation: INSERT_FIRST + value: + name: security-audit.strip-audit-headers + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua + default_source_code: + inline_string: | + function envoy_on_request(request_handle) + local headers = request_handle:headers() + headers:remove("x-sarex-audit-sub") + headers:remove("x-sarex-audit-org") + end diff --git a/infrastructure/security-audit/mesh/telemetry.yaml b/infrastructure/security-audit/mesh/telemetry.yaml new file mode 100644 index 0000000..89dbc8d --- /dev/null +++ b/infrastructure/security-audit/mesh/telemetry.yaml @@ -0,0 +1,17 @@ +# Включает провайдер audit-access (meshConfig.extensionProviders в istiod) на ingress-шлюзе. +# Провайдер должен быть раскатан раньше: иначе istiod не найдёт его и журнал не включится. +apiVersion: telemetry.istio.io/v1 +kind: Telemetry +metadata: + name: gateway-audit-access + namespace: istio-system +spec: + selector: + matchLabels: + security-audit/gateway: unset + accessLogging: + - providers: + - name: audit-access + # Только health-check'и. Статику не исключаем — решение за ИБ. + filter: + expression: '!(request.url_path.startsWith("/healthz") || request.url_path.startsWith("/ready"))' diff --git a/infrastructure/security-audit/uralkal/access-collector.yaml b/infrastructure/security-audit/uralkal/access-collector.yaml new file mode 100644 index 0000000..647fa82 --- /dev/null +++ b/infrastructure/security-audit/uralkal/access-collector.yaml @@ -0,0 +1,17 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: access-collector + namespace: security-audit +spec: + dependsOn: + - name: kafka + namespace: kafka + - name: vault + namespace: vault + values: + extraEnvs: + - name: CONTOUR + value: uralkal + - name: KAFKA_BROKERS + value: kafka-kafka-contour.kafka.svc.cluster.local:9092 diff --git a/infrastructure/security-audit/uralkal/kustomization.yaml b/infrastructure/security-audit/uralkal/kustomization.yaml index dabe7fd..09ec9c6 100644 --- a/infrastructure/security-audit/uralkal/kustomization.yaml +++ b/infrastructure/security-audit/uralkal/kustomization.yaml @@ -2,5 +2,30 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../base + - ../mesh # istio-system; применять после раскатки провайдера audit-access в istio-pilot/uralkal patches: - path: audit-collector.yaml + - path: access-collector.yaml + # Селектор ingress-шлюза (HelmRelease istio-system/ingressgateway): replace, а не merge — + # merge-patch CRD оставил бы и заглушку security-audit/gateway. + - target: + kind: EnvoyFilter + name: strip-audit-headers + patch: |- + - op: replace + path: /spec/workloadSelector/labels + value: {istio: ingressgateway} + - target: + kind: Telemetry + name: gateway-audit-access + patch: |- + - op: replace + path: /spec/selector/matchLabels + value: {istio: ingressgateway} + - target: + kind: NetworkPolicy + name: access-collector + patch: |- + - op: replace + path: /spec/ingress/0/from/0/podSelector/matchLabels + value: {istio: ingressgateway}