Как стать автором
Обновить
79.16
Райффайзен Банк
Развеиваем мифы об IT в банках

Безопасность подов: взгляд пользователя K8s

Уровень сложностиСложный
Время на прочтение20 мин
Количество просмотров2.3K

Про информационную безопасность Kubernetes-кластеров много пишут с позиции специалистов ИБ. Но полезно взглянуть на эту тему глазами обычных пользователей K8s — инженеров и разработчиков. Тех, кто много работает со своими приложениями в подах, но не управляет служебными частями кластера.

Большинство стандартов безопасности описывает лучшие практики настройки управляющих компонентов — control plane. Нечасто встречаются рекомендации по грамотной настройке рабочих единиц — подов. В статье попробуем восполнить этот пробел. Выполним обзор источников, рассмотрим хорошие практики работы с образами. Изучим, как ограничить привилегии контейнера и почему это важно. Поговорим о инструментах автоматической проверки манифестов и разберем примеры GItlab CI пайпланов.

С точки зрения ИБ, интересующий нас подход называется анализом конфигураций на ошибки — misconfiguration analysis. Этот подход давно и успешно применяется в индустрии. Он основан как на формальных стандартах, так и общедоступных базах типовых ошибок. В open-source выложены многочисленные инструменты для автоматической проверки YAML спецификаций.  Каждый из них может стать эффективной частью вашего CI/CD. Но обо всем по порядку.

Стандарты безопасности

В вопросе конфигурации кластера стандарты зачастую описывают настройку управляющих компонентов. Есть ряд исключений, в частности CIS Benchmarks (из состава рекомендаций CIS). Этот стандарт:

  • включает раздел о конфигурации подов

  • используется в стандартных аудитах безопасности 

  • лег в основу базовых требований по ИБ для K8s 

  • реализован в коде через известную утилиту kube-bench 

Интересно, что kube-bench — один из первых примеров open-source инструментов для автоматического анализа настроек Kubernetes.

Во второй половине статьи мы рассмотрим решения, более подходящие для анализа именно подов. Сейчас же обратим внимание на дополнительные источники: Pod Security Standards с официального сайта Kubernetes, а также открытые базы типовых ошибок конфигурирования — такие как база misconfiguration от Aqua Security или набор baseline политик Kyverno.

На основе указанных источников разберем основные направления для снижения поверхности атаки на поды.

Контроль версии образа

Почему не стоит использовать тег latest

Применять «latest»  —  антипаттерн по целому ряду причин. Во-первых, так нарушается observability: трудно понять, какой именно образ будет использоваться при запуске пода. Во-вторых, невозможно обеспечить консистетность образов между разными подами. Особенно, если imagePullPolicy не равна «Always» (об этом ниже).

С точки зрения ИБ это риск: невозможно проверить, действительно ли используется безопасная версия. А с позиции DevOps — подрывается основной принцип GitOps: доверие к репозиторию как единственному источнику правды.  

 imagePullPolicy: Always

Параметр imagePullPolicy определяет, в каких случаях Kubelet должен обращаться к registry за образом при старте пода. На практике часто выставляют значение «IfNotPresent», но это не самое безопасное решение. Обращение к реестру для «IfNotPresent» не выполняется, если на ноде находится образ с искомым именем.

Тогда как для «Always» обращение к registry происходит при каждом старте пода. Есть заблуждение, что любое такое обращение означает повторную загрузку образа из реестра. На деле Kubelet сначала запрашивает в registry хэш от образа по его имени (как в DNS-запросе). Затем сравнивает локальный и полученный хэши. Если такой хэш уже есть на ноде, образ не загружается повторно. 

Другими словами: «IfNotPresent» проверяет только имя образа, а «Always» с помощью обращения к реестру проверяет и имя образа, и его хэш. Таким образом, «Always» позволяет:

  • принудительно обеспечить консистентность образа между подами

  • защититься от подмены образа, если атакующий не озаботился сохранением «легитимного» хэша при подмене

Разберем на примерах

Допустим, в кластере уже запущены несколько подов с образом app:0.0.1. В образе нашли и исправили дефект, после этого его пересобрали, но забыли изменить тег. Если используется «IfNotPresent», теряется консистентность образов. Ноды с кэшированным образом продолжат использовать старую версию, а на всех остальных нодах при первом запуске пода будет загружена новая. 

Если таким же образом в app:0.0.1 исправили уязвимость, то возникает ложная уверенность, что риски ИБ устранены. Хотя на самом деле под продолжит использовать уязвимый образ до момента смены тега.

Наконец, подобное может произойти и по злому умыслу, если в ходе атаки у тега app:0.0.1 целиком подменили содержимое на конкретной ноде (включая манифест с хэшем образа).

«Always» в ImagePullPolicy устраняет все описанные риски — при условии, что мы доверяем registry. С одним важным уточнением — imagePullPolicy: Always полагается на container runtime в вопросе целостности образов. И если container runtime не может обнаружить её нарушение (изменение части файлов при сохранении хэша), то «Always» не поможет. Однако борьба с такими ситуациями — тема отдельной большой статьи.

Ещё одна особенность «Always» — быстрое выявление устаревания ImagePullSecret, поскольку секреты проверяются при каждом старте пода.

Однако обращение к реестру при каждом старте пода имеет существенный минус: если registry недоступен — создание новых и рестарт существующих подов будут заблокированы. Значит, надежность реестра должна быть не ниже, чем у самого приложения.

Собственный реестр образов

В прошлом разделе мы говорили о важности доверия к registry, чтобы успешно контролировать версии образов. Но полностью доверять можно только приватному корпоративному реестру образов. 

Например, известен случай кратковременной потери доступа к Docker Hub из-за того, что публичные реестры попадают под регулирование других стран.

На этом угрозы безопасности не заканчиваются. Используя образы из общедоступного реестра, вы также принимаете риски эксплуатации образов, выложенных неизвестными вам людьми. Вы не знаете, как авторы проверяли свои образы на безопасность и надежность, не можете гарантировать отсутствие злого умысла, не имеете оперативной связи с ними.

Играет роль и размер площадки. Помимо Docker Hub, вендоры выкладывает артефакты в свои небольшие registry. Чем меньше сторонний реестр — тем выше вероятность, что у его владельцев недостаточно ресурсов полноценно выполнить требования ИБ. 

Только в собственном доверенном реестре вы можете организовать проверку собираемых образов на уязвимости, обеспечить их регулярное обновление и стандартизировать процесс именования. Так вы будете уверены в качестве и безопасности используемых образов. После этого целесообразно запретить registry, отличные от корпоративного (как реализовать такую политику в CI/CD, обсудим во второй части статьи).

Hash нотация именования образов

Радикальное решение проблемы контроля используемых версий образов — переход к hash-нотации в спецификациях пода:

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
  - name: app
    image: <image-name>@<digest>

Так мы гарантируем защиту от подмены содержимого тега образа и консистентность образов между подами. Отчасти это альтернатива для «imagePullPolicy: Always», так как не требуется обращение к реестру за хэшами. Существенный минус — снижается читаемость манифестов. Не все команды готовы отказаться от легкочитаемых тегов в пользу хэшей.

Снижение поверхности атаки на образ

Один из распространенных антипаттернов — «распухание» образов. Это ситуация, когда в активно используемый образ постепенно добавляют содержимое. В итоге получается «швейцарский нож»: целевое приложение, утилиты для отладки, набор популярных DevOps-инструментов — и всё это в одном контейнере. 

Такой подход опасен.  Если злоумышленник получит доступ к подобному контейнеру, он сможет использовать встроенные инструменты для расширения и углубления проникновения в Kubernetes кластер. Образ становится плацдармом для атакующего.

Чтобы снизить риски, стоит придерживаться правил:

  1. Разделяйте образы для сборки и развертывания и образы приложений.

  2. Не включайте в образ приложения инструменты для отладки

  3. Минимизируйте содержимое образа.

Пример №1

В одном образе собраны реализация стандартной библиотеки C, компилятор и make. Если атакующий получит контроль над таким контейнером, то сможет собирать и применять эксплоиты «на лету». 

Пример №2

Даже без дополнительных инструментов поверхность атаки может быть широкой. Чем больше в коде приложения зависимостей, тем больше потенциальных уязвимостей внутри образа. 

Борьба с обилием зависимостей в приложении выходит за рамки статьи, поэтому далее рассмотрим варианты, как избежать реализации примера #1.

Alpine

Вопреки популярному мнению, Alpine является полноценным дистрибутивом операционной системы. Это легкая ОС — так как использует BusyBox в качестве системных утилит и musl как реализацию стандартной библиотеки С. Кроме того, ее базовая поставка содержит минимум дополнительных инструментов. Тем не менее пользователь изначально получает shell и пакетный менеджер, т.е. мы имеем полноценную ОС.

Alpine несколько снижает риски, связанные с наличием лишних инструментов, так как:

  • не поставляется вместе с компилятором

  • ее стандартный shell - это /bin/sh

  • содержит musl вместо glibc 

Поэтому меньше шансов, что атакующий сможет адаптироваться к незнакомому окружению. Но, увы, большую часть озвученных векторов атаки Alpine не закрывает.

Slim

Slim-образы — это облегченные версии обычных Docker-образов. Часто их собирают с помощью специальных утилит — например, Slim (ранее DockerSlim). Но они могут быть и результатом произвольной ручной модификации Dockerfile.

Методики сборки не унифицированы, поэтому нельзя сказать, что Slim-образы всегда безопаснее обычных. Тем не менее, если Slim-образ создан утилитой, это хороший старт для обеспечения безопасности подов. Особенно если экспертиза команды пока не позволяет собирать собственные Distroless-образы.

Distroless

В идеале Distoless собирают «FROM scratch» — и образ содержит только целевое приложение и минимально необходимую «обвязку». Давайте сразу рассмотрим пример:

FROM registry.company.ru/go-builder:0.0.1 as builder

WORKDIR /go/src/
COPY . .

RUN go mod download
RUN CGO_ENABLED=0 go build -o app

RUN addgroup -g 10100 app && \
  adduser -u 10100 -G app -D -H -h /nonexistent -s /sbin/nologin app
RUN chown app:app ./app && chmod +x ./app

FROM scratch

COPY --from=builder /go/src/app /usr/local/bin/app

COPY --from=builder /etc/group /etc/passwd /etc/
COPY --from=builder /etc/ssl /etc/ssl

USER app:app

ENTRYPOINT ["/usr/local/bin/app"]

Сборка происходит в builder образе, где имеются необходимые инструменты. Язык Golang позволяет собирать приложение в один исполняемый файл. Собранное приложение, созданный для него пользователь app и корпоративные сертификаты копируются в пустой scratch образ. Такая техника многоэтапной (multi-staged) сборки создает Distroless-образ. Нет ни пакетного менеджера, ни системного shell, ни make, ни компилятора, ни реализации стандартной библиотеки С — только приложение, пользователь и сертификаты. 

Distroless подход минимизирует угрозы для образа. Увы, он не универсален: не каждый язык и не каждое приложение позволяют подобный минимализм. Но с точки зрения информационной безопасности к этому стоит стремиться всегда.

Исключение привилегий и root

Контроль прав и привилегий контейнера настраивается через блок  SecurityContext. Рассмотрим ключевые поля, которые влияют на безопасность подов.

privileged

Пользовательские приложения никогда не должны запускаться в привилегированных контейнерах. 

Если в контейнере с «privileged: true» запускается процесс с root правами, то он имеет полный доступ к хостовой системе.  Это фактически root-доступ к данной ноде кластера. Компрометация привилегированного контейнера ставит под угрозу как минимум целую ноду (и все поды на ней), а как максимум — весь кластер.

Важно: настройка «privileged: false» защищает, даже если процесс был запущен с правами суперпользователя. Лишенный привилегий контейнер не может выйти за пределы своих namespaces. А компрометация такого контейнера не ведет к автоматической угрозе для всего кластера.

runAsNonRoot

Простой способ запретить запуск от root в контейнере. Если в спецификации контейнера задано «runAsNonRoot: true», Kubelet не запустит контейнер с UID=0. То есть возможен старт контейнера с любым UID (под любым пользователем), кроме root.

Важно, что «runAsNonRoot: true» защитит только от явного запуска приложения под рутом. Эта настройка никак не поможет, если атакующий использовал механизм эскалации прав через setuid.

allowPrivilegeEscalation

«allowPrivilegeEscalation: false» защищает от атак, когда в результате использования setuid или setgid обычный процесс приобретает root-права
Часто защиту от эскалации прав понимают неправильно, поэтому рассмотрим на примере. 

Допустим, в контейнере запущено приложение с UID = 10100. Но в образе есть файл с правами суперпользователя и выставленным setuid-битом:

RUN chown root:root ./app && chmod +s ./app

При разрешенной эскалации прав такой файл может быть использован для получения root полномочий.

Для запрета на эскалацию прав Kubernetes выставляет флаг ядра no_new_privs у процесса в контейнере. Теперь ни один дочерний процесс не может получить больше прав, чем имел его родитель, и невозможна эскалация через setuid или setgid.

Подчеркну, что «allowPrivilegeEscalation: false» — это не мера первого приоритета. Сначала необходимо запретить привилегированные контейнеры, затем root и лишь потом — эскалацию прав.

readOnlyRootFilesystem

Эта настройка помогает быстро снизить поверхность атаки на под: при «readOnlyRootFilesystem: true» приложение не может менять файловую систему контейнера. Запись разрешена только в volumes, явно указанные в спецификации контейнера. Это позволяет определить, с какими участками файловой системы приложению допустимо работать. И существенно ограничивает атакующего в возможности покинуть контейнер.

Важно: не все типы volumes безопасны. С точки зрения ИБ работа с типом:

spec.volumes[*].hostPath

запрещена. Даже если такой volume используется только для чтения — лучше вообще избегать его для production инсталляций. 

Допустимые и безопасные типы volumes:

spec.volumes[*].configMap
spec.volumes[*].csi
spec.volumes[*].downwardAPI
spec.volumes[*].emptyDir
spec.volumes[*].ephemeral
spec.volumes[*].persistentVolumeClaim
spec.volumes[*].projected
spec.volumes[*].secret

Этого списка достаточно для большинства приложений с грамотно выстроенной архитектурой.

Ограничение потребления ресурсов

Имеется ложное мнение об автоматическом контроле за потреблением ресурсов подами. Будто бы ресурсы подов ограничиваются Kubernetes «из коробки». Это не так. Разумеется, Kubernetes поддерживает cgroups и опирается на них для ограничения доступных контейнеру ресурсов. Однако их требуется выставлять в явном виде для каждого из контейнеров.

Pod без выставленных ограничений может использовать все ресурсы ноды, где запущен, став угрозой для ее стабильности. Такое может произойти не только в результате целенаправленной атаки. Достаточно простой перегрузки приложения неожиданным наплывом трафика. Или ошибок при управлении памятью. Любой из этих сценариев в худшем случае ведет к нарушению работы целого кластера. Так происходит, поскольку поды будут циклически переселятся с загруженной ноды на свободные, где сценарий может повториться. Возникшую «петлю нестабильности» придется решать команде поддержки кластера в ручном режиме.

Чтобы этого не случилось, необходимо задать ограничения с помощью специальной сущности — limits. При этом важно не перепутать limits с requests.  Как мы увидим далее, limits напрямую отображаются в cgroups, а requests используются для информирования планировщика (Kubernetes Scheduler).

limits

В первом приближении limits задает значения, передаваемые в cgroups. Точнее, при создании пода его высокоуровневая YAML спецификация передается из Kubelet в СRI. Там происходит процесс отображения (трансляции) в низкоуровневую спецификацию, непосредственно связанную с cgroups. Рассмотрим такую часть спецификации пода:

spec:
  containers:
  - image: app
    resources:
      requests:
        memory: "64Mi"         
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"

Она будет отображена в следующую JSON нотацию (пример дается по cgroups-v1, для конверсии в v2 можно воспользоваться таблицей):

"info": {
    "runtimeSpec": {
      "hostname": "app",
      "linux": {
        "resources": {
          "memory": {
            "limit": 134217728,
            "swap": 134217728
          },
          "cpu": {
            "shares": 256,
            "quota": 50000,
            "period": 100000
          },
        },
}}}}

Как видно, requests здесь нет. Можно решить, что они не отображаются в параметры cgroups вовсе, но это не так — спасибо коллегам за уточнение в комментариях. Не вдаваясь в детали, отметим, что для CPU requests отображаются в shares — гарантированную долю процессорного времени для этой cgroup. А для памяти подобного отображения на самом деле нет. Почему так — тема для отдельной статьи, подробно затронутая в источнике So You Want to Be a Wizard: How Kubernetes CPU Requests and Limits Actually Work.

Вернувшись к нашему примеру, заметим, что заданные в MiB ограничения по памяти пересчитываются в фактическое число байт для данной cgroup. Поэтому важно различать единицы измерения MiB и M — на большом объеме памяти разница может оказаться заметной. Наконец, ограничения по процессорному времени записываются как принято для сgroups — в виде отношения quota/period (для v1).

Поскольку limits отобразились в конкретную cgroup, можно безошибочно считать, что приложение было ограничено. Теперь атака на истощение ресурсов невозможна. В случае запроса памяти сверх лимита контейнер будет «убит» с помощью специального механизма ядра — OOM Killer. Если же приложению потребуется больше разрешенного процессорного времени — оно его просто не получит.

requests

В прошлом разделе показано, что у requests имеется своя специфика при отображении в объекты ядра Linux. Она вызвана тем, что requests сообщают планировщику о потребности контейнера (и управляющего им пода) в ресурсах.  Планировщик с учетом прочих ограничений выбирает наиболее подходящую ноду для размещения пода. Если же необходимого количества ресурсов найдено не было, планировщик сообщит об ошибке вида:

0/48 nodes are available: 10 Insufficient memory, 38 Insufficient cpu

Существенно, что планировщик оперирует только с requests. Значение limits, как и реального потребления ресурсов, не учитываются. Таким образом, итоговый механизм работы с ресурсами таков: 

  • requests — это заявление о потребности контейнера в ресурсах, обрабатываемое планировщиком при распределении подов по нодам

  • limits — это предел потребления ресурсов ноды, жестко задаваемый для каждого контейнера с помощью cgroups

Статический анализ манифестов

Выше мы рассмотрели наглядные примеры хороших и плохих практик при написании спецификаций для контейнеров. Не затронули вопрос полных манифестов для подов и не обсуждали работу с управляющими сущностями (Deployment, StatefulSet). Но даже так проверок набралось больше, чем можно регулярно выполнять в ручном режиме. Очевидно, что для систематического соблюдения лучших практик требуются инструменты статического анализа манифестов. 

Как упоминалось во введении, таких инструментов в open-source сегменте достаточно. Для удобства разделим их на две группы: валидаторы и линтеры.

Валидаторы работают на основе OpenAPI-схем Kubernetes-ресурса. Упрощенно: OpenAPI-схема представляет K8s-ресурс как структуру данных вида ключ-значение произвольной вложенности. Такая структура может быть также описана как JSON-схема, что удобно для автоматической валидации. Вне зависимости от типа схемы, она ограничивает возможные значения для каждого уровня вложенности. Это позволяет в автоматическом режиме отслеживать недопустимые значения на всех уровнях манифеста. Что-то вроде проверки типов на этапе компиляции у императивных языков.

Линтеры выполняют анализ конфигурации на соответствие неким правилам, аналогично привычным инструментам для императивных языков. В зависимости от инструмента это может быть как закрытое множество прописанных в коде проверок, так и «песочница» для написания собственных политик.

Строго говоря, группа валидаторов не является решением задачи данной статьи. Однако они просты в освоении, легки в использовании, создают прочное основание для CI/CD пайплайна проверки манифестов. Поэтому начнем именно с них. 

kubeconform

kubeconform — известный open-source валидатор для манифестов. Утилита часто попадает в подборки профильных инструментов и не раз упоминалась здесь, на Хабре. Ее главная особенность — готовая интеграция с наборами JSON-схем.  Мейнтейнеры проектов kubernetes-json-schema и CRDs-catalog проделали огромную работу по сбору JSON-схем для «ванильного» Kubernetes (первый репозиторий), а также для всех или почти всех популярных CRD (второй репозиторий). Спасибо им за труд.

Однако схемы создают основную сложность интеграции kubeconform в пайплайн. Для его работы требуется регулярное обращение к обоим упомянутым репозиториям. Но многие корпоративные окружения не позволяют CI/CD инфраструктуре работать с внешней Сетью. Очевидное решение — создать собственное зеркало. К сожалению, для схем «ванильного» K8s имеется дополнительная сложность — большой размер репозитория. Более 20 Гб при глубине клонирования в один коммит. Упростить задачу помогает загрузка схем лишь для тех версий Kubernetes, которые используются в данный момент. Поскольку работа со sparse-checkout может быть неочевидной, давайте рассмотрим пример:

git clone --branch "master" --single-branch --depth 1 --no-checkout \
	--filter=tree:0 https://github.com/yannh/kubernetes-json-schema.git \
	$KUBERNETES_JSON_SCHEMA_REPO_PATH
cd $KUBERNETES_JSON_SCHEMA_REPO_PATH
git sparse-checkout set --no-cone /master-standalone-strict
git checkout

Размер частичной копии позволяет разместить ее прямо в Docker-образе. Так мы можем объединить в одном образе инструмент и схемы. Это особенно полезно при параллельных запусках на большом числе манифестов, где сетевые задержки становятся заметны. Например, если пайплайн отрабатывает для Kustomize-репозитория. Примером job для Gitlab CI может быть:

---
.kubeconform:
  variables:
    REPORTS_PATH: $CI_PROJECT_DIR/reports
    KUBECONFORM_REPORT_FILEPATH: $REPORTS_PATH/kubeconform.xml
    TARGET_YAML_FILEPATH: $REPORTS_PATH/all.yml
  image: registry.company.ru/kubeconform:0.2.0
  before_script:
    - mkdir -p $REPORTS_PATH
  script:
      >-
      kubeconform
      -schema-location /usr/local/share/kubernetes-json-schema
      -schema-location \
        /usr/local/share/kubernetes-crds-catalog/\
        {{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json
      -strict
      -output junit
      $TARGET_YAML_FILEPATH > $KUBECONFORM_REPORT_FILEPATH
  artifacts:
    when: always
    reports:
      junit: $KUBECONFORM_REPORT_FILEPATH
    paths:
      - $REPORTS_PATH
...

Почему одного kubeconform недостаточно, чтобы решить поставленную в статье задачу? Потому что с помощью валидаторов мы можем автоматически проверить лишь то, что тип данных для каждого из полей соответствует заданному в схеме. Определить таким образом ошибку конфигурации невозможно.  Однако валидатор сокращает петлю обратной связи для разработчика манифестов. Проблема с типом данных будет обнаружена на этапе коммита, а не при обработке ответа от KubeAPI кластера.

kube-score

Раз валидаторы в одиночку не решают проблему, перейдем к линтерам. Из множества инструментов рассмотрим один из самых простых в применении — kube-score. Это тоже весьма известная утилита, предоставляющая готовый набор политик. В составе предустановленного набора правил:

  • большая часть рассмотренных выше рекомендаций

  • проверки для управляющих ресурсов (Deployment, StatefulSet)

  • не рассмотренные в рамках статьи PodDisruptionBudget и podAntiAffinity

Использование kube-score проще, чем kubeconform: не требуются какие-либо дополнительные схемы. Если собрать небольшой Docker-образ с анализатором, то вариант job для Gitlab CI может быть таким:

---
.kube_score:
  variables:
    REPORTS_PATH: $CI_PROJECT_DIR/reports
    KUBESCORE_IGNORE_LIST: "."
    KUBESCORE_REPORT_FILEPATH: $REPORTS_PATH/kubescore.xml
    TARGET_YAML_FILEPATH: $REPORTS_PATH/all.yml
  image: registry.company.ru/kubescore:0.1.0
  before_script:
    - mkdir -p $REPORTS_PATH
  script:
    - echo "Following rules will be ignored $KUBESCORE_IGNORE_LIST"
    - >-
      kube-score
      score
      --ignore-test $KUBESCORE_IGNORE_LIST
      --output-format junit
      $TARGET_YAML_FILEPATH > $KUBESCORE_REPORT_FILEPATH || true
    - >-
      kube-score
      score
      --ignore-test $KUBESCORE_IGNORE_LIST
      --output-format human
      $TARGET_YAML_FILEPATH
  artifacts:
    when: always
    reports:
      junit: $KUBESCORE_REPORT_FILEPATH
    paths:
      - $REPORTS_PATH
...

Как видно из примера, здесь мы используем «костыль» с двумя запусками kube-score: чтобы сохранить результаты в Junit-виджет Gitlab, а также вывести в консоль в человекочитаемом виде. 

К сожалению, простота использования имеет свои минусы: kube-score содержит закрытый набор политик, прописанных в коде инструмента. Т.е. расширить проверки возможно лишь одним образом — собрать свой форк утилиты.

Итак, kube-score отлично дополняет kubeconform в качестве базовой реализации пайплайна проверки манифестов. Такая комбинация закрывает как задачу быстрой валидации манифеста, так и ряд рисков по ИБ.  При этом не требует затрат времени на настройку и необходимости разбираться во внутреннем устройстве инструментов.

Тем не менее для полноценной проверки манифестов на безопасность и надежность необходима поддержка кастомизируемых правил и политик.

Тестирование манифестов

Настраиваемые политики для проверки манифестов находятся на стыке между статическим анализом и тестированием. В рамках статьи несущественно, считать ли это расширением понятия статического анализа или относить к специфическим автотестам. Однако создатели обозреваемых инструментов позиционируют их именно как средства тестирования.

helm-unittest

Этот инструмент — средство классического юнит-тестирования, но применительно к helm-чартам. helm-unittest устанавливается как плагин к helm и запускается без каких-либо хитростей:

.helm_unittest:
  variables:
    REPORTS_PATH: $CI_PROJECT_DIR/reports
    CHART_PATH: $CI_PROJECT_NAME
    HELMUNITTEST_REPORT_FILEPATH: $REPORTS_PATH/helmunittest.xml
  image: registry.company.ru/helm-unittest:0.1.0
  before_script:
    - mkdir -p $REPORTS_PATH
  script:
    - >
      helm unittest
      --output-type junit
      --output-file $HELMUNITTEST_REPORT_FILEPATH
      $CHART_PATH
  artifacts:
    when: always
    reports:
      junit: $HELMUNITTEST_REPORT_FILEPATH
    paths:
      - $REPORTS_PATH
...

Устройство тестов при этом сложно назвать простым. С одной стороны, они быстро пишутся и легко поддерживаются, так как это человекочитаемый YAML. С другой стороны — это полноценный DSL, располагающий большим набором средств. Чтобы использовать его эффективно, нужно адаптировать тест-дизайн под возможности DSL. Приведу пример:

suite: kube app tests
values:
  - ../values.yaml
tests:
  - it: should render deployment correctly for all cases
    templates:
      - ../charts/kube-app/templates/deployment.yaml
    asserts:
      - isKind:
          of: Deployment
      - equal:
          path: spec.replicas
          value: 2
      - matchRegex:
          path: spec.template.spec.containers[0].image
          pattern: *distroless_image
      - equal:
          path: spec.template.spec.containers[0].resources.limits.cpu
          value: *limits_cpu
      - equal:
          path: spec.template.spec.containers[0].resources.limits.memory
          value: *limits_memory
      - equal:
          path: spec.template.spec.containers[0].resources.requests.cpu
          value: *requests_cpu
      - equal:
          path: spec.template.spec.containers[0].resources.requests.memory
          value: *requests_memory
  ...

Если рассматривать такой тест, как safeguard политику для Deployment, то в целом проблем нет. Мы убеждаемся: реплик две, образ называется в соответствии с принятым паттерном, реквесты и лимиты выставлены. Но вот с точки зрения тест-дизайна тест не атомарен. Лучше было бы разбить на отдельные блоки и проверять ровно одну вещь каждым тестом. Однако такой подход резко увеличит количество boilerplate кода, а наглядность YAML-теста снизится. Наконец, потребуется еще один агрегирующий уровень абстракции (suites).

Обсуждение оптимального тест-дизайна применительно к YAML-тестам требует отдельной статьи. А в рамках текущей нам достаточно иллюстрации, что настраиваемые проверки — это непросто. Минимальный порог входа резко повышается, для достижения высокой эффективности работы с инструментом требуется обучение.

Что даже более важно: helm-unittest подходит только для helm-чартов. Скорее всего, его адаптация для готовых манифестов возможна. Но для адаптации потребуется доработка кода инструмента. А создание своего форка избыточно для большинства команд. 

Таким образом, инвестиции в освоение этого инструмента оправданы, если вы работаете в основном с helm-чартами. В противном случае — потребуется средство более общего назначения.

Rego based engines

На текущий момент самые широкие возможности по работе с манифестами предлагают движки на языке Rego. Есть менее известные проекты, позволяющие писать политики на императивных языках (Go, Rust), например, kubewarden. Однако Rego остается стандартом в этой области. Поэтому далее будем рассматривать только варианты на его основе.

Rego создавался как декларативный язык для работы со структурированными документами. Из-за этого он имеет ряд особенностей, а также довольно высокий порог входа. Несмотря на неоднозначную обратную связь в профильных сообществах, он является основным для проекта Open Policy Agent (OPA) и вдохновленных им инструментов. Для наглядности давайте сразу рассмотрим вот такой пример:

package pod.security

deny contains sprintf("image '%s' comes from untrusted registry",
  [container.image]) if {
	input.kind == “Deployment"
	some container in input.spec.template.spec.containers
	not startswith(container.image, "hub.docker.com/")
}

Здесь мы декларируем модуль, содержащий группу политик (package), и одну единственную политику. Политика парсит входной документ (input) и проверяет, что это спецификация для Deployment. Затем она проходит по всем контейнерам в спецификации пода и запрещает получать образы с Docker Hub.

Подобным образом можно реализовать запрет на работу с недоверенными registry для манифестов любого происхождения. Как и любую другую кастомную политику произвольной сложности без необходимости менять исходный код инструмента.

Основной минус всех подобных решений — это сам Rego. Судя по обратной связи в сообществах, непонятна сама декларативная концепция языка. Для helm-unittest создатели инструмента выбрали традиционный понятийный аппарат юнит-тестов: тесты, ассерты, тестовые наборы и т.д. Тогда как в Rego нужно работать с пакетами (модулями) политик, декларативно применяемых к документам.

Однако замены Rego в задаче настраиваемой проверки манифестов просто нет. При этом раздел умышленно назван «Rego based engines»: у движка нет альтернатив, но есть несколько реализаций в виде CLI-утилит.

Во-первых, это conftest — дочерний к OPA проект. Это простой CLI-интерфейс проверки структурированных документов с помощью Rego-политик:

conftest test \
--namespace pod.security \
–-policy my_policies \
all.yaml

Здесь namespace — это название модуля (пакета) с политиками, policy — директория, содержащая написанные политики, all.yaml — подаваемый на вход отрендеренный манифест. А пример job для GitLab CI выглядит так:

.conftest:
  variables:
    REPORTS_PATH: $CI_PROJECT_DIR/reports
    CONFTEST_REPORT_FILEPATH: $REPORTS_PATH/conftest.xml
    TARGET_YAML_FILEPATH: $REPORTS_PATH/all.yml
  image: registry.company.ru/conftest:0.1.0
  before_script:
    - mkdir -p $REPORTS_PATH
  script:
    - >-
      conftest
      test
      --namespace pod.security
      –-policy /usr/local/share/my_policies
      --output junit
      $TARGET_YAML_FILEPATH > $CONFTEST_REPORT_FILEPATH
  artifacts:
    when: always
    reports:
      junit: $CONFTEST_REPORT_FILEPATH
    paths:
      - $REPORTS_PATH
...

Как видно, с точки зрения пайплайна, получается в целом несложно — это похоже на работу с kubeconform. За исключением того, что мы снаряжаем инструмент не JSON-схемами, а Rego-политиками.

Аналогия с kubeconform наталкивает на вопрос: можно ли воспользоваться готовыми базами Rego-политик для снижения сложности первоначального внедрения инструмента? К счастью, это возможно — если применить другую реализацию Rego-движка, поставляемую в комплекте с уже написанными политиками. 

Например, trivy-checks — вынесенная в open-source часть обширной экосистемы Trivy. Для целей статьи важно, что инструмент поставляется с большой базой готовых политик, на которую мы уже ссылались в начале теоретического блока. При этом trivy-checks работает с кастомными Rego-политиками аналогичным conftest образом. Можно написать несколько специфичных для команды или проекта политик, а всё остальное взять из готовой базы. Неслучайно мы заметили, что Rego-движок объединяет целую группу инструментов. 

На этом данное обсуждение заканчивается — мы достигли поставленных во введении целей. Давайте в завершение посмотрим на краткие итоги того, что получилось.

Заключение

В статье мы разобрали базовые рекомендации для обеспечения безопасности подов. Однако нельзя считать их исчерпывающими. Тема информационной безопасности Kubernetes гораздо шире, даже если ограничиться только безопасностью приложений и сопутствующих K8s сущностей. Тем не менее следование советам из статьи сделает ваши контейнеры безопаснее и существенно снизит поверхность атаки на поды.

Грамотно построенный CI/CD — это непосредственная имплементация стандартов качества и безопасности для команды на долгие годы. Приведенные в статье примеры работы с open-source инструментами в составе пайплайна CI/CD призваны дать общее представление о том, как может выглядеть такой пайплайн. Уверен, что можно сделать лучше и интереснее, а главное — максимально адаптировать к нуждам команды. Если хотите обсудить, как этого добиться — добро пожаловать в комментарии. 

Спасибо за внимание!

Теги:
Хабы:
+15
Комментарии7

Публикации

Информация

Сайт
www.raiffeisen.ru
Дата регистрации
Дата основания
1996
Численность
5 001–10 000 человек
Местоположение
Россия

Истории