Я всегда был уверен, что любой инструмент, достаточно мощный, чтобы решить вашу проблему, достаточно мощен и для того, чтобы создать вам новую. Как оказалось, применительно к движкам политик безопасности эта мысль оказалась верной на все сто.
Привет, Хабр! Меня зовут Алексей Рыбалко, и я занимаюсь в ЛК вопросами защиты контейнеров и контейнерной разработки. Поскольку у нас в ЛК все строится вокруг безопасности, то защита Kubernetes — первостепенная задача, и я решил поделиться в этой статье нашим с коллегами опытом по защите Runtime в K8s.
Когда речь заходит о проверках безопасности в Kubernetes, на ум в первую очередь приходят готовые средства: Pod Security Admission, OPA Gatekeeper, Kyverno. Они хорошо закрывают базовый уровень: привилегированные контейнеры, отсутствие лимитов, запуск от root. Однако реальные требования ИБ редко умещаются в набор из десятка предустановленных проверок.
Здесь кроется ловушка: как только политика перестает быть тривиальной, начинаются две разные проблемы, и обе они инженерные.
Ловушка сложных политик
Первая проблема касается выразительности. Требование вида «если образ тянется из внешнего реестра, проверить не только тег, но и наличие подписи и сверить владельца Deployment с разрешенным списком» на декларативном движке превращается в изрядный объем кода. Его нужно написать, отладить, покрыть тестами и поддерживать при каждом изменении требований.
Вторая проблема касается скорости. Admission-контроль стоит на критичном по скорости пути: пока webhook не вернул вердикт, объект в кластере не создается. В нагруженном кластере, где в минуту рождаются сотни подов, время обработки одной проверки перестает быть какой-то абстрактной цифрой. Десятки миллисекунд задержки на каждый запрос складываются в заметный налог на весь конвейер развертывания.
Какой из этого следует вывод? Движок проверок — не деталь реализации, которую можно выбрать по привычке. Это компонент на горячем пути, от которого зависит и скорость кластера, и то, насколько дорого обходится сопровождение политик.
Чем платят за выразительность
Прежде чем перейти к тому, что мы выбрали, честно разложим варианты. У движка политик можно выделить несколько подходов.
Подход | Описание | Плюсы | Минусы |
PSA (Pod Security Admission) | Встроенный в Kubernetes набор фиксированных уровней | Ничего не ставить, быстро | Не расширяется, только про Pod Security |
OPA Gatekeeper (Rego) | Полноценный язык политик с собственной ВМ | Максимальная выразительность, кастомные сообщения, сложные сценарии | Отдельный язык (смена парадигмы), тяжелый рантайм, задержки на сложных политиках |
Kyverno | Политики в виде YAML-манифестов | Привычный формат, не нужен новый язык | Многословность, YAML плохо выражает сложную логику |
CEL (Common Expression Language) | Язык выражений, компилируемый в AST | Легкий, быстрый, C-подобный синтаксис, встроен в Kubernetes API | Меньшая выразительность: нет циклов, нет кастомных сообщений |
В таблице указаны именно различные подходы к политикам: у Kyverno и в Kubernetes сейчас также начали применять подход с CEL, помимо указанных.
Rego силен выразительностью. Это полноценный декларативный и функциональный язык: ветвления, итерация по коллекциям, формирование текстовых сообщений об отказе. За эту силу платят двумя вещами. Первая цена — это порог входа: императивному разработчику приходится перестраивать мышление. Вторая — рантайм: Rego интерпретируется собственной виртуальной машиной, и на сложных политиках поверх больших JSON-объектов задержки измеряются десятками миллисекунд.
CEL устроен иначе. Это язык выражений с открытым исходным кодом от Google, тот самый, на котором работают CRD Validation Rules в самом Kubernetes API. Выражение компилируется в абстрактное синтаксическое дерево (AST) и вычисляется без запуска тяжелого движка. Синтаксис C-подобный и знаком любому, кто писал условия в обычном коде: has(), &&, ||, exists(), all(), filter(). Инженеру не нужно осваивать новый язык запросов. Условие пишется почти как есть.
В данной статье мы разберем три вещи: как CEL и Rego соотносятся по скорости на реальных сценариях KCS, как выглядит анатомия проверки на CEL и почему при выборе движка мы смотрели не только на производительность, но и на модель угроз. Последнее оказалось решающим.
Производительность: замеры на трех сценариях
Команда разработки прогнала три типовых для KCS сценария на обоих движках локально. Схема одинаковая: на вход подается JSON, компилируется правило, правило применяется ко входу, замеряется время каждой фазы.
Сценарий 1 (Custom Benchmark). Валидация ресурса Kubernetes: запрет привязки
cluster-adminк группеsystem:masters.Сценарий 2 (DAC). Admission-контроль: запрет образа
nginxна создании Pod.Сценарий 3 (Assurance Policy). Проверка отчета о сканировании образа (~200 КБ) на наличие эксплойтов у уязвимостей.
Результаты по фазе вычисления (Eval — собственно применение правила ко входу):
Сценарий | Rego, Eval | CEL, Eval | Общее Rego | Общее CEL |
1. Custom Benchmark | 293 мкс | 70 мкс | 1,42 мс | 1,93 мс |
2. DAC | 141 мкс | 15 мкс | 507 мкс | 280 мкс |
3. Assurance (~200 КБ) | 3,84 мс | 24 мкс | 7,02 мс | 3,09 мс |
Картина честная, и она двоякая. В сценарии 1 CEL проигрывает по общему времени. Разница в компиляции: при построении AST CEL делает дополнительный проход, и на маленьком правиле фаза компиляции съедает выигрыш. Но это разовая цена: правила компилируются один раз при загрузке, а фаза Eval гоняется на каждый проверяемый объект.
И вот на Eval разрыв виден сразу. На большом объекте сценария 3 CEL вычисляет правило за 24 мкс против 3,84 мс у Rego, на два порядка быстрее, и общее время получается вдвое меньше. По микробенчмаркам Go CEL держит больше итераций за прогон и аллоцирует примерно вдвое меньше объектов, то есть и по памяти он экономнее.
Вывод команды разработки был таким: в сценариях DAC, Custom Benchmark и Assurance Policy выразительность Rego нам не нужна: мы не строим сложных цепочек с дополнительными входными данными, нам нужен ответ true/false за минимальное время и при минимальной памяти. Для этого профиля нагрузки CEL подходит лучше.
Анатомия проверки
Посмотрим, как проверка выглядит в коде. Возьмем сценарий 1 — запрет cluster-admin для system:masters.
CEL:
resources.exists(res,
res.kind == "ClusterRoleBinding" &&
res.subjects.exists(sub, sub.kind == "Group" && sub.name == "system:masters"))Rego:
package kubernetes.validation
violation contains msg if {
some resource in input.resources
resource.kind == "ClusterRoleBinding"
some subject in resource.subjects
subject.kind == "Group"
subject.name == "system:masters"
msg := sprintf("Запрещена привязка cluster-admin к группе system:masters: %s", [resource.metadata.name])
}Обе проверки делают одно и то же. Разница видна на глаз. У CEL это одно выражение с макросами exists, читается сверху вниз как обычное условие. У Rego — блок правил с итерацией some … in и формированием сообщения sprintf. Ровно здесь Rego показывает свою выразительность: он может вернуть человекочитаемый текст с именем нарушившего ресурса. CEL вернет только true или false.
Набор макросов и функций CEL закрывает большинство практических проверок. Из правил, которые QA гоняет по объектам Kubernetes:
// хотя бы один контейнер привилегированный
request.object.spec.containers.exists(c,
has(c.securityContext) && c.securityContext.privileged == true)
// все контейнеры привилегированные
request.object.spec.containers.all(c,
has(c.securityContext) && c.securityContext.privileged == true)
// ровно один контейнер привилегированный
request.object.spec.containers.exists_one(c,
has(c.securityContext) && c.securityContext.privileged == true)
// в правах Role есть wildcard
request.object.rules.exists(r, '*' in r.verbs)Обратите внимание на has() перед обращением к вложенному полю: в CEL это идиома защиты от отсутствующего ключа, аналог проверки на nil перед разыменованием. Документация KCS прямо рекомендует ставить has() до обращения к вложенным полям: has(event.principal.role) && event.principal.role == "admin". Есть и функции приведения типов (int(), double(), timestamp(), duration(), bytes()), и работа со временем через параметр текущего времени now.
А теперь про безопасность
До этого момента история звучит как обычный размен «скорость против выразительности». Но настоящая причина выбора глубже, и она про модель угроз.
Универсальный движок политик внутри продукта безопасности сам по себе фактор риска. Через него администратор описывает, к каким данным и как обращаться. А значит, тот, кто получил доступ к описанию правил, получает и рычаг доступа к данным. Вопрос лишь в том, насколько длинный этот рычаг.
Как CEL работает в KCS
Чтобы понять поверхность атаки, посмотрим на путь правила от интерфейса до вердикта.
Администратор задает правило CEL в веб-интерфейсе kcs-panel.
Правило сохраняется в СУБД PostgreSQL.
По каналу HTTPS правило передается на агенты.
Дальнейшее зависит от типа проверки:
kube-agent (
csp-kube-agent) по правилу запрашивает Kube-API-Server и сверяет с условием манифесты объектов, которые тот возвращает;node-agent (
csp-node-agent) применяет правило к заранее подготовленным данным хоста: содержимому файлов, правам доступа, аргументам процессов.
Агент выносит вердикт
true/false, гдеtrueозначает, что проверка провалена (ресурс non-compliant).
[схема: путь правила CEL — kcs-panel → PostgreSQL → HTTPS → node-agent / kube-agent → Kube-API / данные хоста → вердикт]
Ключевой момент здесь — шаг 4 для node-agent. Проверка содержимого файла означает, что выражение CEL получает доступ к тексту конфигов на worker-нодах. А среди этих файлов бывают такие, где лежат токены и секреты.
Модель угроз: внутренний нарушитель
Команда выходного контроля ИБ по разработке KCS ставила задачу так: обход механизмов аутентификации и авторизации (внутренний нарушитель) либо компрометация СУБД с подменой таблиц правил не должны повлиять на клиентские кластеры или ущерб должен быть минимальным. При этом приоритеты расставлены явно: кража секретов с worker-нод здесь опаснее, чем падение кластера.
И вот на этой модели угроз разница между Rego и CEL перестает быть вопросом вкуса.
Rego Тьюринг-полон по возможностям: ветвления, циклы, богатая стандартная библиотека. Внутренний злоумышленник, дотянувшийся до описания политик, может с его помощью выстроить логику извлечения секретных данных с worker-нод и выполнить множество разных действий. Это слишком длинный рычаг для движка, который ходит по файлам на узлах кластера.
CEL Тьюринг-неполон: нет циклов, нет ветвлений-как-в-скриптах, нет способа накрутить произвольную логику. Казалось бы, вопрос закрыт. Но здесь есть тонкость. Сама схема, где данные запрашиваются на основе описанных политик, фактически превращает язык доступа к структурам данных в средство их подбора. Это противоречит безопасной архитектуре, в которой данные уже собраны и защищены, а движок обращается к ним лишь по фиксированным правилам.
Проще говоря: даже на Тьюринг-неполном CEL в исходном виде можно написать «правило-оракул» для посимвольного подбора секрета. Например, такое:
file.content.matches('(?s).*token:\\s+[A-Z]{3}(\\n|$).*')
Регулярное выражение здесь работает как проверка гипотезы о содержимом файла. Меняя шаблон и наблюдая вердикт true/false, можно посимвольно подбирать значение секрета. Механизм касается содержимого файлов, аргументов команд, прав доступа и объектов K8s, везде, где доступны matches() (regexp), contains(), startsWith() и endsWith().
Что с этим сделали
Аудит как первая линия
Встроенная мера: аудит-лог KCS. При попытке подбора секрета через CEL решение фиксирует повторное применение одного и того же бенчмарка по одному параметру от конкретной учетной записи, и эти события уходят в SIEM-систему.
Мера снижает риск, но, честно скажем, не снимает его полностью. SIEM может быть не настроен. Оператор может пропустить алерт. Наконец, злоумышленник с доступом к kcs-panel может отключить отправку в SIEM. Поэтому нужна вторая линия, на уровне самого языка.
Урезание функций языка
Задачу сформулировали так: не дать подбирать содержимое файлов на worker-нодах посимвольно. Решение в текущей реализации KCS:
функции
matches(),startsWith()иendsWith()на проверке содержимого файла блокируются; правило с ними завершается с ошибкой (Failed);функции
contains()разрешают работать, но к искомому значению добавляется символ конца строки (EOL).
Что это дает. Без regexp и проверок префикса/суффикса нельзя строить шаблон произвольной формы. А добавление \n к аргументу contains() превращает посимвольный подбор в поиск строки целиком: правило срабатывает, только если искомое совпадает с целой строкой файла до перевода строки, а не с любой ее подстрокой. Подобрать секрет по одному символу так уже нельзя: нужно угадать всю строку сразу.
Оговоримся честно: это снижает риск, а не обнуляет его. Часть операций сравнения языка остается доступной, так что полностью закрыть поверхность подбора одним урезанием функций нельзя, аудит и SIEM остаются необходимой частью защиты. Полный периметр складывается из обеих мер, а не из одной.
Инженерная вставка: как это реализовано в cel-go
Отдельно стоит показать, как урезание функций делается в cel-go, потому что первый очевидный путь не работает.
Наивная попытка: взять стандартную среду cel.NewEnv(), исключить встроенный contains через StdLibSubset и объявить свой.
func newEnv() (*cel.Env, error) {
return cel.NewEnv(
// стандартная библиотека без встроенного "contains"
cel.StdLib(cel.StdLibSubset(&env.LibrarySubset{
ExcludeFunctions: []*env.Function{{Name: "contains"}},
})),
cel.Variable("object", cel.DynType),
// заново объявляем "contains" со своим поведением
cel.Function("contains",
cel.MemberOverload(
"string_contains_string_nl",
[]*cel.Type{cel.StringType, cel.StringType},
cel.StringType,
cel.BinaryBinding(customContains),
),
),
)
}Такой код падает в панику еще до проверки правил:
panic: function declaration merge failed: overload signature collision in function contains: contains_string collides with string_contains_string_nl
Причина в том, что cel.NewEnv() поверх исключения все равно подмешивает стандартный набор, и объявления сталкиваются. Рабочее решение: собирать окружение с нуля через cel.NewCustomEnv(), который не тянет стандартную библиотеку автоматически, и передавать в него уже отфильтрованный список функций и макросов:
func newEnv() (*cel.Env, error) {
return cel.NewCustomEnv( // было cel.NewEnv
cel.StdLib(cel.StdLibSubset(&env.LibrarySubset{
ExcludeFunctions: []*env.Function{{Name: "contains"}},
})),
cel.Variable("object", cel.DynType),
cel.Function("contains",
cel.MemberOverload(
"string_contains_string_nl",
[]*cel.Type{cel.StringType, cel.StringType},
cel.StringType,
cel.BinaryBinding(customContains),
),
),
)
}В боевой реализации contains переопределяется так, чтобы дописывать \n к аргументу, а matches, startsWith и endsWith подменяются на заглушки, возвращающие ошибку «функция не поддерживается для данного типа ресурса». Проверить поведение можно на небольшом стенде на cel-go, скармливая ему файл и правило по отдельности, ровно так, как рекомендует и документация KCS: обкатывать правила в изолированной среде перед применением.
Что в итоге
Выбор CEL для проверок в Kaspersky Container Security связан не только со скоростью, хотя на фазе вычисления CEL стабильно быстрее Rego и экономнее по памяти, а на больших объектах разрыв достигает двух порядков. Он в равной мере про модель угроз: Тьюринг-неполнота языка укорачивает рычаг, который получает внутренний нарушитель, дотянувшийся до описания политик, а урезание строковых функций закрывает самый прямой путь подбора секретов на worker-нодах.
За это платят выразительностью. На CEL не написать кастомное сообщение об отказе, не выстроить сложный сценарий с дополнительными входными данными. Для профиля задач KCS (DAC, Custom Benchmarks, Assurance Policies) этот размен оправдан: здесь важнее гибкости полноценного языка политик получить быстрый и предсказуемый true/false при управляемой поверхности атаки.
Ни одна из мер защиты не самодостаточна. Урезание функций снижает поверхность подбора, аудит с выгрузкой в SIEM ловит остаточные попытки, и работают они только вместе. Если вы проектируете собственный слой политик поверх CEL, закладывайте обе линии сразу.
Рекомендации по правилам CEL
Практические советы, которые стоит держать под рукой при написании правил.
Используйте простые конструкции: CEL заточен под короткие логические выражения.
Формулируйте правило так, чтобы результат был
trueилиfalse.Применяйте функции приведения (
int(event.attributes.ip),timestamp(event.attributes.time)), не полагаясь на автоматический вывод типа.Не злоупотребляйте
size(),contains()и регулярными выражениями на больших массивах: по отдельности они быстрые, но частое применение к большим коллекциям дорого.Ставьте
has()перед обращением к вложенным полям.Не полагайтесь на порядок вычисления подвыражений, в CEL он не определен; побочных эффектов в языке нет.
Обкатывайте правила в изолированной среде (например, на cel-go) до применения.
Помечайте цель проверки комментарием рядом с выражением.
Безопасность через скорость как идея на будущее
Есть у CEL еще одно свойство, которое мы пока используем не в полную силу, а оно открывает интересный ход. Код на выходе у CEL предсказуем по стоимости: еще до запуска правила можно оценить, насколько дорого обойдется его вычисление.
Это прямое следствие компиляции в AST. У CEL есть встроенная модель стоимости (англ. cost): статический анализатор проходит по дереву выражения и выдает оценку сложности в безразмерных единицах (cost units), а рантайм умеет считать фактическую стоимость и прерывать вычисление при превышении бюджета. На этой самой модели, кстати, стоит ограничение CEL в самом Kubernetes: правила валидации CRD и admission-выражения не пропускаются, если их расчетная стоимость выходит за заданный лимит.
У Rego такого свойства нет. Стоимость произвольной политики на Тьюринг-полном языке в общем случае не предсказать статически: цикл по коллекции неизвестного размера или рекурсивное правило дают время выполнения, которое видно только на конкретных данных.
Чтобы не рассуждать умозрительно, мы прогнали правила из этой статьи через cost-модель cel-go (env.EstimateCost для статической оценки и OptTrackCost для фактической стоимости на конкретном входе). Ниже две метрики: фактическая стоимость на небольшом объекте (Pod с двумя контейнерами, короткие строки) и статический верхний предел для «большого» входа, список до 256 элементов, строки до 4096 символов. Именно верхний предел важен для контроля доступа: cost-limiter отсекает правило по худшему случаю, а не по среднему.
Операция | Фактическая стоимость | Статический предел (большой вход) | Как растет |
| 6 | 3 | константа |
| 6 | 3 | константа |
обращение к полю + | 9 | 4 | константа |
| 8 | 3 | константа |
| 8 | 412 | линейно от длины строки |
| 10 | 822 | от длины строки и шаблона |
| 36 | 6152 | от длины строки и шаблона |
| 16–23 | 1538–2050 | от размера списка × строки |
Числа говорят сами за себя. На маленьком входе разброс невелик, но именно статический предел показывает, чего стоит операция в худшем случае. Сравнение и обращение к полю по времени константа вне зависимости от размера данных. А сложное регулярное выражение по длинной строке дает предел в 6152 единицы против 3 у обычного == — разница более чем в две тысячи раз. Любопытная деталь: startsWith() и endsWith() в cel-go тоже константны, дорожает именно contains() и семейство matches(), потому что они сканируют строку целиком.
И вот к чему это ведет. Раз стоимость правила известна заранее, ее можно сделать элементом контроля доступа. В матрице прав пользователей KCS напрашивается новый уровень градации по стоимости проверок: менее привилегированные пользователи запускают только «дешевые» правила в пределах условного бюджета (скажем, статический предел до 100 единиц, куда укладываются сравнения, has(), size(), префиксные проверки и умеренные exists), а «дорогие» проверки, в которые входят потенциально опасные операции вроде регулярных выражений по содержимому файлов, доступны только особо привилегированным ролям.
Красота хода в том, что он снимает необходимость урезать язык для всех сразу. Сейчас функции matches(), startsWith() и endsWith() заблокированы на содержимом файлов для всех без разбора. Стоимостная градация позволяет вернуть в CEL весь набор логических функций и при этом сохранить контур защиты: полный язык доступен там, где роль это оправдывает, а массовый пользователь по-прежнему ограничен быстрыми и безопасными проверками. Периметр держится не запретом функции, а привязкой ее стоимости к уровню доверия.
Пока это концепция в стадии изучения, а не функция в продукте. Открытых вопросов хватает: как калибровать пороги стоимости под реальное железо узлов, как соотнести статическую оценку с фактическим временем на разных входных данных, где провести границы ролей. Будем рады комментариям коллег к статье — если у вас есть идеи или опыт по стоимостным моделям в движках политик, поделитесь в обсуждении.