ADG Оставить заявку
Блог DevOps 5 мин чтения

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-сопровождение - это в том числе про такой слой автоматических проверок, которые работают всегда, не только когда у конкретного инженера хватило внимания.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.