OPA в Terraform CI: plan не применяется, если открывает 0.0.0.0/0 или создаёт IAM-роль с admin
Добавили Open Policy Agent в Terraform-пайплайн: политика живёт в git рядом с кодом, блокирует опасные изменения до apply и проходит code review наравне с инфрой.
Terraform Sentinel и Open Policy Agent - политики безопасности как код для IaC-конвейеров
Проблема, с которой мы столкнулись, формулируется просто: Terraform разрешает делать всё что написано в коде, а написать в коде можно что угодно. Security group с правилом 0.0.0.0/0 на 22 порт, IAM-роль с AdministratorAccess, S3-бакет с публичным доступом - это не ошибки синтаксиса, план пройдёт и apply применится. Единственный барьер - это ревью, а ревью люди делают по-разному и иногда устают.
Мы хотели барьер, который не устаёт.
Почему не Sentinel
HashiCorp Sentinel - очевидный первый ответ. Он встроен в Terraform Enterprise и позволяет писать политики на специальном языке, которые блокируют apply. Проблема в том, что у нас Terraform OSS, а не Enterprise. Sentinel в OSS недоступен - это честная платная фича.
Open Policy Agent подходит на ту же роль и работает с terraform plan output в JSON-формате. Сам OPA - опенсорс, написан на Go, принимает данные и политику на Rego, возвращает решение. Никакой привязки к конкретному оркестратору.
Архитектурно решение выглядит так:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
opa eval --data policy/ --input tfplan.json "data.terraform.deny"
Если data.terraform.deny возвращает непустое множество - CI падает, apply не запускается, пайплайн показывает конкретное нарушение.
Что пишем в политиках
Начали с двух правил, которые закрывают самые популярные самострелы.
Первое - открытый SSH/RDP на весь интернет. Security group с ingress-правилом на CIDR 0.0.0.0/0 или ::/0 и портом 22 или 3389 - блокируем. Rego выглядит читаемо:
package terraform
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.type == "ingress"
resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
resource.change.after.from_port <= 22
resource.change.after.to_port >= 22
msg := sprintf("Security group rule '%s' открывает SSH на 0.0.0.0/0", [resource.address])
}
Логика линейная, аргумент в deny - это сообщение об ошибке, которое увидит инженер в CI-логе.
Второе - IAM-роль с AdministratorAccess. Любое создание или изменение IAM-политики, которая содержит "Action": "*" и "Resource": "*", блокируется:
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_role_policy"
policy := json.unmarshal(resource.change.after.policy)
statement := policy.Statement[_]
statement.Effect == "Allow"
statement.Action == "*"
statement.Resource == "*"
msg := sprintf("IAM-политика '%s' выдаёт admin-доступ (*/*)", [resource.address])
}
Это не ловит все варианты - wildcard может быть списком, политика может быть managed, а не inline. Покрываем итеративно.
Политика живёт в git
Это, пожалуй, главное наблюдение. Файлы .rego лежат в директории policy/ рядом с .tf-кодом, в том же репозитории. Изменение политики - это merge request, который проходит ревью наравне с изменением инфраструктуры.
Это меняет отношение к политикам. Если политика живёт где-то в системе доступа или в конфиге CI отдельно - её меняет тот кто имеет доступ к этой системе, и изменение не видно в обычном потоке ревью. Когда политика в git - её видят все, diff читается, обсуждение идёт в том же месте где обсуждается код. Кто-то захотел ослабить проверку - написал зачем, получил апрув или не получил.
Мы немного подвинули политику в сторону GitOps-подхода, который обкатывали раньше: всё важное в git, всё изменяется через PR, всё аудируемо.
Как это встало в пайплайн
GitLab CI, стек на Terraform 0.12:
policy-check:
stage: validate
image: openpolicyagent/opa:0.11.0
script:
- terraform plan -out=tfplan
- terraform show -json tfplan > tfplan.json
- opa eval --fail-defined --data policy/ --input tfplan.json "data.terraform.deny"
only:
- merge_requests
Стадия validate запускается до apply. Если OPA вернул нарушения - apply просто не запустится, задача упадёт на предыдущем шаге. Флаг --fail-defined означает: если запрос вернул что-то непустое - exit code ненулевой.
Запуск на merge request, а не только на main - это принципиально. Инженер узнаёт о нарушении до того, как смёрджил, а не после того, как попытался применить.
Что сразу же поймали
В первую же неделю после включения: разработчик добавлял тестовый стенд и для быстроты открыл security group на 0.0.0.0/0. MR создан, пайплайн упал на policy-check, в логах конкретное сообщение с адресом ресурса. Инженер исправил CIDR на конкретный диапазон, пайплайн прошёл.
Без OPA: ревью MR могло пройти незамеченным, стенд поднялся бы с открытым SSH, и лежал бы так пока кто-нибудь не наткнулся или не провёл аудит.
Что пока не покрыто
Честно - много. Terraform plan в JSON даёт доступ к планируемым изменениям, но не к текущему состоянию всей инфраструктуры. Проверки типа «суммарное количество публичных ресурсов» или «нет ли уже такого правила в другом ресурсе» - это не про plan, это про state. OPA для этого тоже используют, но через другую интеграцию - и это следующий шаг.
Также Rego требует привыкания. Язык декларативный, логика работает иначе чем в привычных языках, и первые попытки написать правило с вложенными массивами ощущаются как угадайка. Документация у OPA хорошая, но без практики первый день уходит на то, чтобы понять почему правило не срабатывает там где должно.
Сейчас в политиках семь проверок. Добавляем по мере того, как в ревью появляются паттерны, которые хочется автоматизировать. Инфраструктурная безопасность через managed-сопровождение - это в том числе про такой слой автоматических проверок, которые работают всегда, не только когда у конкретного инженера хватило внимания.