Add audit collector HelmRelease for security-audit namespace to process and export kube-apiserver audit logs, including Kafka and Vault integration

This commit is contained in:
emelinda 2026-11-01 22:02:54 +03:00
parent 5525cc33ff
commit ad7050ca7d
8 changed files with 472 additions and 0 deletions

View File

@ -0,0 +1,153 @@
# security-audit
Сбор журнала аудита kube-apiserver с мастеров в Kafka-топик `security.k8s-audit`.
Одно сообщение — одно событие аудита в JSON (`encoding: raw`, без OTLP-обёртки), обогащённое полями `sarex_*`.
Дальнейшие этапы (processor → MinIO/Parquet) — отдельными компонентами.
С observability-коллектором (`infrastructure/opentelemetry-collector`) не связан: отдельный релиз, namespace и конфиг.
| Оверлей | Статус |
|---|---|
| `uralkal` | манифесты готовы, **в `clusters/uralkal` не подключён** |
## Что делает коллектор
DaemonSet `audit-collector-agent` на узлах `node-role.kubernetes.io/control-plane`. Он читает
`/var/log/audit/kube-apiserver-audit*.log` (текущий и ротированные файлы), позиции хранит в `/var/lib/otelcol-audit`
(hostPath), туда же складывает дисковую очередь отправки.
Обработка (`transform/audit`):
- **вырезание шума:** `metadata.managedFields` и аннотация `kubectl.kubernetes.io/last-applied-configuration`
в `requestObject` / `responseObject`;
- **маскирование:** значения параметров `password|passwd|pwd|token|secret|api_key|api-key` в `requestURI`
(`token=abc` → `token=***`), сама команда `exec` не маскируется;
- **классификация** — поля ниже. Правила применяются по порядку, последнее подходящее побеждает.
| Поле | Значение | Условие |
|---|---|---|
| `sarex_contour` | имя контура | env `CONTOUR` из оверлея |
| `sarex_dry_run` | `true` / `false` | `requestURI` содержит `dryRun=All` |
| `sarex_action` | `read` | `verb` ∈ `get`, `list` |
| | `mutate` | `verb` ∈ `create`, `update`, `patch`, `delete`, `deletecollection` |
| | `status` | `objectRef.subresource == status` |
| | `interactive` | `objectRef.subresource` ∈ `exec`, `attach`, `portforward` |
| `sarex_outcome` | `applied` | код 2xx, не dry-run |
| | `dry_run` | код 2xx, dry-run |
| | `denied_authn` | 401 |
| | `denied_authz` | 403 и `authorization.k8s.io/decision=forbid` |
| | `denied_admission` | 4xx при `decision=allow`, кроме 404/409/422 |
| | `invalid` | 422 |
| | `conflict` | 409 |
| | `not_found` | 404 |
| | `error` | 5xx |
| `sarex_actor_type` | `human` | по умолчанию |
| | `serviceaccount` | `system:serviceaccount:*` |
| | `controller` | `system:serviceaccount:kube-system:*`, `system:kube-*`, `system:apiserver` |
| | `node` | `system:node:*` |
| | `gitops` | `system:serviceaccount:flux-system:*` |
`watch` не попадает ни в одну категорию `sarex_action`: поле не выставляется.
Надёжность доставки: `required_acks: -1`, дисковая очередь (`sending_queue.storage`), бесконечные ретраи. При
заполненной очереди срабатывает backpressure на `filelog`, чтение файла приостанавливается. Данные теряются,
только если файл успеет уйти из ротации (10 × 100 МБ) раньше, чем Kafka вернётся.
## Предусловия подключения в контуре
1. **Аудит apiserver включён** (INFRA-3057): флаги `--audit-policy-file`, `--audit-log-path=/var/log/audit/kube-apiserver-audit.log`,
`--audit-log-maxsize=100`, `--audit-log-maxbackup=10`. Каталог `/var/log/audit` есть на всех мастерах.
2. **Kafka:** топик и SCRAM-пользователь.
- Топик `security.k8s-audit`: 1 партиция, `retention.ms=259200000` (3 суток), `max.message.bytes=5242880`,
RF по контуру (в `uralkal` один controller → RF 1).
- Пользователь `security-audit` (SCRAM-SHA-512). ACL не нужны.
Заводится terraform'ом: `infra/terraform`, ветка `contour`, модуль `kafka-topics-incluster`, блок
`environments.<контур>` в `infrastructure.yaml`. Если нужно вручную — из пода контроллера, через внутренний
листенер (`INTERNAL`, 9094, `SASL_PLAINTEXT`/`PLAIN`, `inter_broker_user`):
```bash
kubectl -n kafka exec -it kafka-kafka-contour-controller-0 -c kafka -- bash
```
```bash
cat > /tmp/admin.properties <<EOF
security.protocol=SASL_PLAINTEXT
sasl.mechanism=PLAIN
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required username="inter_broker_user" password="$(cat /vault/secrets/kafka-inter-broker-password)";
EOF
B=localhost:9094
kafka-topics.sh --bootstrap-server $B --command-config /tmp/admin.properties --create \
--topic security.k8s-audit --partitions 1 --replication-factor 1 \
--config retention.ms=259200000 --config max.message.bytes=5242880
kafka-configs.sh --bootstrap-server $B --command-config /tmp/admin.properties --alter \
--entity-type users --entity-name security-audit \
--add-config 'SCRAM-SHA-512=[iterations=4096,password=<пароль>]'
rm /tmp/admin.properties
```
3. **Vault** (`secrets/` — KV v2):
- секрет `secrets/data/security-audit/kafka` с ключами `username`, `password`, `ca_crt`;
`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`, политика выше.
Заводится в `infra/terraform`, ветка `contour`, модуль `vault-platform-secrets` (роли `vault_kubernetes_auth_backend_role`,
KV-секреты).
4. **Образ** `cr.yandex/crp3ccidau046kdj8g9q/opentelemetry-collector/opentelemetry-collector-contrib:0.121.0-amd64`
доступен в registry контура (тот же, что дефолт чарта `opentelemetry-collector-prod` 0.117.1), `regcred` есть в
namespace `security-audit`.
## Подключение
Отдельным MR после выполнения предусловий — добавить в `clusters/uralkal/kustomization.yaml`:
```yaml
- ../../infrastructure/security-audit/uralkal
```
`dependsOn` в оверлее ждёт HelmRelease `kafka/kafka` и `vault/vault`.
## Проверка после подключения
- DaemonSet `security-audit/audit-collector-agent` только на мастерах, все поды `Ready`:
`kubectl -n security-audit get pods -o wide`.
- В логах коллектора нет ошибок: `kubectl -n security-audit logs ds/audit-collector-agent -c opentelemetry-collector-prod`.
- События в топике (из пода контроллера Kafka, с `admin.properties` выше):
`kafka-console-consumer.sh --bootstrap-server localhost:9094 --consumer.config /tmp/admin.properties --topic security.k8s-audit --max-messages 5`.
- `kubectl create secret generic audit-probe --from-literal=k=v` → событие с `objectRef.resource=secrets` уровня
`Metadata`, без `requestObject`/`responseObject`.
- Рестарт пода коллектора (`kubectl delete pod`) → нет дублей и пропусков: сверить `auditID` в топике с файлом журнала
на мастере за интервал рестарта.
## Известное ограничение
Значения env с паролями внутри `requestObject` / `responseObject` workload-ресурсов на уровне `RequestResponse`
коллектор не маскирует: OTTL плохо работает с массивами (`spec.containers[].env[]`). Маскирование будет в processor'е.
До его готовности не повышать уровень для workload-ресурсов выше `Request` в контурах, где пароли могут быть в env.
## Проверить перед подключением
Сверено по репозиторию и чартам 01.11.2026, доступа к кластеру не было.
| Что | Результат | Как проверено |
|---|---|---|
| Образ и `command.name` | `.../opentelemetry-collector/opentelemetry-collector-contrib:0.121.0-amd64`, `otelcol-contrib` | дефолт `values.yaml` чарта `opentelemetry-collector-prod` 0.117.1 (appVersion 0.120.0, тег образа 0.121.0); `otelcol-contrib components` в образе: filelog, transform, kafka, file_storage, health_check, memory_limiter, batch есть |
| Синтаксис kafka exporter | для 0.121.0 корректен (`topic`/`encoding` наверху, `auth.tls`) | `otelcol-contrib validate` в том же образе — OK |
| Kafka Service и порт | `kafka-kafka-contour.kafka.svc.cluster.local:9092`, `SASL_SSL` (**не** `kafka.kafka...`) | `helm template` чарта `kafka-contour` 0.1.15 с values `uralkal`; тот же адрес у `apps/*/uralkal` |
| CA Kafka | Secret `kafka/kafka-kafka-contour-tls`, ключ `ca.crt` (helm `genCA`, 365 дней) | шаблон `tls-secret.yaml` чарта; `apps/issues/uralkal` монтирует его же |
| Kafka 4.0.0 и `protocol_version: 2.8.0` | совместимо (Kafka 4 требует ≥ 2.1) | `appVersion` чарта `kafka-contour` |
| Vault | HelmRelease `vault/vault`; injector ходит на `http://vault-vault-contour.vault.svc:8200` | `infrastructure/vault/uralkal`, `helm template` `vault-contour` 0.2.3 |
| Узлы мастеров | метка и 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 |
Остаётся открытым:
- `TODO(verify)` **nodelocaldns** (`169.254.25.10`) в NetworkPolicy: в репозитории не подтверждается. При дефолте kubespray
включён, если нет — правило безвредно.
- Некритичные предупреждения 0.121.0: `service::telemetry::metrics::address` устарел (так же во всех коллекторах репо);
OTTL-пути без префикса `log.` (контекст выводится автоматически).

View File

@ -0,0 +1,242 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: audit-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: daemonset
fullnameOverride: audit-collector
# Дефолт чарта 0.117.1 (appVersion 0.120.0), задан явно: contrib-сборка с filelog, transform,
# kafka exporter, file_storage, health_check, memory_limiter. Зеркало в registry контура — см. README.
image:
repository: cr.yandex/crp3ccidau046kdj8g9q/opentelemetry-collector/opentelemetry-collector-contrib
tag: "0.121.0-amd64"
command:
name: otelcol-contrib
imagePullSecrets:
- name: regcred
# Только мастера: журнал аудита есть только там.
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
rollout:
rollingUpdate:
maxUnavailable: 1
# Доступ к Kubernetes API коллектору не нужен.
clusterRole:
create: false
serviceAccount:
create: true
name: audit-collector # = роль Vault security-audit, bound SA
presets:
logsCollection: {enabled: false}
hostMetrics: {enabled: false}
kubernetesAttributes: {enabled: false}
kubeletMetrics: {enabled: false}
kubernetesEvents: {enabled: false}
clusterMetrics: {enabled: false}
# В daemonset-режиме чарт вешает hostPort на OTLP/Jaeger/Zipkin: приёмники не нужны,
# а hostPort конфликтует с другими DaemonSet-коллекторами на тех же узлах.
ports:
otlp: {enabled: false}
otlp-http: {enabled: false}
jaeger-compact: {enabled: false}
jaeger-thrift: {enabled: false}
jaeger-grpc: {enabled: false}
zipkin: {enabled: false}
metrics: {enabled: false}
service:
enabled: false
# Журнал аудита — 0600 root:root. Root нужен как владелец файла, capabilities не нужны.
podSecurityContext:
runAsUser: 0
runAsGroup: 0
seccompProfile: {type: RuntimeDefault}
securityContext:
runAsUser: 0
runAsNonRoot: false
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
privileged: false
capabilities:
drop: [ALL]
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi}
extraVolumes:
- name: audit-log
hostPath: {path: /var/log/audit, type: DirectoryOrCreate}
- name: otelcol-storage
hostPath: {path: /var/lib/otelcol-audit, type: DirectoryOrCreate}
extraVolumeMounts:
- {name: audit-log, mountPath: /var/log/audit, readOnly: true}
- {name: otelcol-storage, mountPath: /var/lib/otelcol-audit}
podAnnotations:
sidecar.istio.io/inject: "false"
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
# Чарт рендерит podAnnotations через toYaml | tpl: однострочный шаблон toYaml переносит,
# и литерал {{ "..." }} рвётся. Поэтому по строке на действие (block scalar не переносится).
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
file_storage/audit:
directory: /var/lib/otelcol-audit
compaction:
on_start: true
directory: /var/lib/otelcol-audit
receivers:
filelog/audit:
include:
- /var/log/audit/kube-apiserver-audit*.log # текущий и ротированные (lumberjack)
start_at: beginning
storage: file_storage/audit # позиции чтения переживают рестарт
max_log_size: 8MiB # дефолт 1MiB режет RequestResponse
poll_interval: 1s
include_file_name: false
operators:
- type: json_parser
parse_to: body
processors:
memory_limiter:
check_interval: 1s
limit_mib: 400
spike_limit_mib: 100
transform/audit:
error_mode: ignore
log_statements:
- context: log
statements:
# --- объём и шум ---
- delete_key(body["requestObject"]["metadata"], "managedFields") where body["requestObject"] != nil
- delete_key(body["responseObject"]["metadata"], "managedFields") where body["responseObject"] != nil
- delete_key(body["requestObject"]["metadata"]["annotations"], "kubectl.kubernetes.io/last-applied-configuration") where body["requestObject"] != nil
- delete_key(body["responseObject"]["metadata"]["annotations"], "kubectl.kubernetes.io/last-applied-configuration") where body["responseObject"] != nil
# --- маскирование (п.23): значения, а не сама команда exec ---
- replace_pattern(body["requestURI"], "(?i)(password|passwd|pwd|token|secret|api[_-]?key)(=|%3D)[^&]*", "$$1$$2***")
# --- обогащение ---
- set(body["sarex_contour"], "${env:CONTOUR}")
- set(body["sarex_dry_run"], IsMatch(body["requestURI"], "[?&]dryRun=All"))
# --- действие ---
- set(body["sarex_action"], "read") where body["verb"] == "get" or body["verb"] == "list"
- set(body["sarex_action"], "mutate") where body["verb"] == "create" or body["verb"] == "update" or body["verb"] == "patch" or body["verb"] == "delete" or body["verb"] == "deletecollection"
- set(body["sarex_action"], "status") where body["objectRef"]["subresource"] == "status"
- set(body["sarex_action"], "interactive") where IsMatch(body["objectRef"]["subresource"], "^(exec|attach|portforward)$")
# --- исход ---
- set(body["sarex_outcome"], "applied") where body["responseStatus"]["code"] >= 200 and body["responseStatus"]["code"] < 300 and body["sarex_dry_run"] == false
- set(body["sarex_outcome"], "dry_run") where body["responseStatus"]["code"] >= 200 and body["responseStatus"]["code"] < 300 and body["sarex_dry_run"] == true
- set(body["sarex_outcome"], "denied_authn") where body["responseStatus"]["code"] == 401
- set(body["sarex_outcome"], "denied_authz") where body["responseStatus"]["code"] == 403 and body["annotations"]["authorization.k8s.io/decision"] == "forbid"
- set(body["sarex_outcome"], "denied_admission") where body["responseStatus"]["code"] >= 400 and body["responseStatus"]["code"] < 500 and body["annotations"]["authorization.k8s.io/decision"] == "allow" and body["responseStatus"]["code"] != 404 and body["responseStatus"]["code"] != 409 and body["responseStatus"]["code"] != 422
- set(body["sarex_outcome"], "invalid") where body["responseStatus"]["code"] == 422
- set(body["sarex_outcome"], "conflict") where body["responseStatus"]["code"] == 409
- set(body["sarex_outcome"], "not_found") where body["responseStatus"]["code"] == 404
- set(body["sarex_outcome"], "error") where body["responseStatus"]["code"] >= 500
# --- субъект (порядок важен: сначала общее, потом частное) ---
- set(body["sarex_actor_type"], "human")
- set(body["sarex_actor_type"], "serviceaccount") where IsMatch(body["user"]["username"], "^system:serviceaccount:")
- set(body["sarex_actor_type"], "controller") where IsMatch(body["user"]["username"], "^system:serviceaccount:kube-system:") or IsMatch(body["user"]["username"], "^system:(kube-|apiserver)")
- set(body["sarex_actor_type"], "node") where IsMatch(body["user"]["username"], "^system:node:")
- set(body["sarex_actor_type"], "gitops") where IsMatch(body["user"]["username"], "^system:serviceaccount:flux-system:")
batch:
send_batch_size: 500
send_batch_max_size: 1000
timeout: 2s
exporters:
kafka/audit:
brokers:
- ${env:KAFKA_BROKERS}
protocol_version: 2.8.0
topic: security.k8s-audit
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: 5242880
sending_queue:
enabled: true
storage: file_storage/audit # очередь на диске: Kafka недоступна → данные не теряются
queue_size: 10000
num_consumers: 2
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 60s
max_elapsed_time: 0 # повторять бесконечно; при полной очереди — backpressure на filelog
service:
extensions: [health_check, file_storage/audit]
pipelines:
logs/audit:
receivers: [filelog/audit]
processors: [memory_limiter, transform/audit, batch]
exporters: [kafka/audit]
telemetry:
logs:
level: info
metrics:
address: ${env:MY_POD_IP}:8888

View File

@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: security-audit
resources:
- namespace.yaml
- networkpolicy.yaml
- helmrelease-collector.yaml

View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: Namespace
metadata:
name: security-audit
labels:
# hostPath (/var/log/audit, чекпоинты) запрещён профилем baseline — нужен privileged.
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/warn: baseline
pod-security.kubernetes.io/audit: baseline
istio-injection: disabled

View File

@ -0,0 +1,32 @@
# Коллектору нужны только DNS, Kafka и Vault (агент-инжектор). Входящие — только health-check kubelet'а.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-collector
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
ingress:
- ports:
- {port: 13133, protocol: TCP} # health_check (liveness/readiness)
egress:
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kube-system}
podSelector:
matchLabels: {k8s-app: kube-dns}
- ipBlock: {cidr: 169.254.25.10/32} # nodelocaldns kubespray; TODO(verify): enable_nodelocaldns в контуре
ports:
- {port: 53, protocol: UDP}
- {port: 53, protocol: TCP}
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kafka}
ports:
# client-листенер SASL_SSL: bootstrap kafka-kafka-contour и advertised-адреса подов controller-headless
- {port: 9092, protocol: TCP}
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: vault}
ports:
- {port: 8200, protocol: TCP} # vault-vault-contour.vault.svc:8200 (AGENT_INJECT_VAULT_ADDR)

View File

@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base

View File

@ -0,0 +1,18 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: audit-collector
namespace: security-audit
spec:
dependsOn:
- name: kafka
namespace: kafka
- name: vault
namespace: vault
values:
extraEnvs:
- name: CONTOUR
value: uralkal
# Service bitnami-чарта kafka-contour (релиз kafka); тот же адрес у приложений apps/*/uralkal.
- name: KAFKA_BROKERS
value: kafka-kafka-contour.kafka.svc.cluster.local:9092

View File

@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../base
patches:
- path: audit-collector.yaml