Entenda os problemas de não possuir um processo de autorização em pipelines de infraestrutra como código e veja uma solução com um bom custo-benefício para mitigar esses problemas.
Introdução
Autorização é sempre um tema delicado, pois envolve a segurança de um software ou processo. Porém, quando falamos de infraestrutura como código, ter guardrails se torna ainda mais importante, pois é possível que um desenvolvedor cometa um erro e acabe expondo dados sensíveis ou até mesmo derrubando um ambiente inteiro.
Pensando em um ambiente corporativo dinâmico, o processo de gerenciamento de software moderno inclui a execução de pipelines de CI/CD para implantação de alterações, tanto de infraestrutura quanto de aplicação. Essas alterações podem ser feitas por desenvolvedores, engenheiros DevOps ou até mesmo por um usuário mal-intencionado.
Com o objetivo de mitigar esses riscos, é possível utilizar ferramentas de autorização que podem ser usadas para validar infraestrutura como código, como por exemplo o Open Policy Agent. O OPA é um motor de políticas open source, leve e de propósito geral, capaz de ser utilizado em diversos cenários como: autorização de APIs e microserviços, admission controller no kubernetes, autorização de tópicos e filas e também autorização de infraestrutura como código.
O problema
Digamos que você possua em sua empresa um processo de build e deploy, automatizado e bem definido. Para fins demonstrativos, vamos supor que sua pipeline de CI/CD execute usando o Github Actions e esse workflow específico seja responsável de realizar a criação de um bucket s3 em sua conta AWS utilizando Terraform como IaC. Considere este repositório como referência e vamos analisar a estrutura do projeto:
1
2
3
4
5
6
7
8
9
10
11
12
.
├── .github
│ └── workflows
│ └── deploy.yml
└── iac
├── .gitignore
├── backend.tf
├── main.tf
├── outputs.tf
├── provider.tf
├── terraform.tfvars
└── variables.tf
O diretório .github
é responsável por abrigar os workflows de CI/CD, que contem o arquivo de workflow deploy.yml
. O diretório iac é responsável por abrigar os arquivos de configuração do Terraform, que são os arquivos backend.tf
, main.tf
, outputs.tf
, provider.tf
, terraform.tfvars
e variables.tf
.
O arquivo deploy.yml
é responsável por executar o deploy do bucket s3 na conta AWS. Este workflow é executado quando um push é realizado na branch main
. Veja abaixo o conteúdo do arquivo deploy.yml
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
name: iac authz example workflow
on:
push:
branches:
- main
paths-ignore:
- ".gitignore"
- "README.md"
permissions:
id-token: write
contents: read
env:
TF_BACKEND: iac-authz-example-tf
AWS_REGION: $
defaults:
run:
shell: bash
jobs:
terraform-backend:
name: Ensure terraform backend
runs-on: ubuntu-latest
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: $
aws-region: $
- name: Create terraform backend
run: |
if [[ -z $(aws s3api list-buckets --query 'Buckets[?Name==`$`]' --output text) ]]; then
aws s3 mb s3://$
fi
aws s3api head-object --bucket bucket-name --key terraform.tfstate || NOT_EXIST=true
if [ $NOT_EXIST ]; then
aws s3api put-object --bucket $ --key terraform.tfstate
fi
terraform:
name: Deploy infrastructure
needs: terraform-backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: iac
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: $
aws-region: $
- name: Setup terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_wrapper: false
- name: Terraform fmt
run: terraform fmt -check
- name: Terraform init
run: |
terraform init -upgrade \
-backend-config="bucket=$" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=$"
- name: Terraform validate
run: terraform validate -no-color
- name: Terraform plan
run: terraform plan -no-color -var-file terraform.tfvars
- name: Terraform apply
run: terraform apply -auto-approve -var-file terraform.tfvars
- name: Terraform outputs
id: tf-outputs
run: |
echo "bucket-arn=`terraform output -raw bucket_arn`" >> $GITHUB_OUTPUT
No arquivo deploy.yml
, temos dois jobs, o primeiro é responsável por criar o bucket s3 que será utilizado como backend do Terraform, enquanto o segundo é responsável por executar o deploy do do terraform do projeto em si, neste caso o bucket s3.
Agora, vamos pensar em algumas regras básicas de segurança quando tratamos buckets s3 corporativamente:
- Necessidade de garantir que os buckets sejam privados
- Necessidade de garantir que os buckets sejam criptografados
- Necessidade de garantir que os buckets tenham versionamento habilitado
Podemos denominar que estas regras em conjunto caracterizam uma baseline de segurança
especificamente para o recurso s3 da AWS. Não possuir uma autorização na pipeline de infraestrutura que garanta que essas regras estejam sendo aplicadas pode resultar em arquivos sensíveis sendo expostos, perda de arquivos, danos de imagem, danos financeiros e entre outros.
Aplicando autorização de IaC
Com o entendimento da estrutura do projeto e o problema exposto, vamos partir para uma solução que mitiga estes riscos. A ideia é ter um step no workflow onde podemos validar o plano do terraform a ser executado e aplicar a baseline de segurança.
Política de autorização
O primeiro passo aqui seria expressar a baseline de segurança do s3 que definimos acima em políticas na linguagem rego, por questões de simplicidade, iremos embutir as políticas de segurança no mesmo repositório do exemplo. Entretanto tenha em mente que em um ambiente corporativo, as políticas de segurança devem ser mantidas em um repositório separado, para que possam ser versionadas, auditadas e fora do alcance de alterações não autorizadas.
Vamos criar um diretório chamado policy
e dentro dele um arquivo chamado main.rego
com o seguinte conteúdo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import data.baseline.aws.common
import data.baseline.aws.s3
import future.keywords.if
default allow := false
allow if {
s3.baseline_valid
}
result["allowed"] := allow
result["violations"] := s3.violations
E também vamos criar um diretório chamado baseline
e dentro dele um diretório chamado aws
e um arquivo chamado s3.rego
que contem a baseline de segurança do s3, com o conteúdo a seguir:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package baseline.aws.s3
import future.keywords.if
import input as tfplan
baseline_valid if {
s3_bucket_public_access__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_public_access_block"]
s3_bucket_server_side_encryption_configuration__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_server_side_encryption_configuration"]
s3_bucket_versioning__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_versioning"]
s3__public_access_disabled(s3_bucket_public_access__changes)
s3__bucket_cryptography_enabled(s3_bucket_server_side_encryption_configuration__changes)
s3__bucket_versioning_enabled(s3_bucket_versioning__changes)
}
violations["S3 - Bucket should block all public access"] {
s3_bucket_public_access__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_public_access_block"]
not s3__public_access_disabled(s3_bucket_public_access__changes)
}
violations["S3 - Bucket should be encrypted"] {
s3_bucket_server_side_encryption_configuration__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_server_side_encryption_configuration"]
not s3__bucket_cryptography_enabled(s3_bucket_server_side_encryption_configuration__changes)
}
violations["S3 - Bucket should be versioned"] {
s3_bucket_versioning__changes := [r | r := tfplan.resource_changes[_]; r.type == "aws_s3_bucket_versioning"]
not s3__bucket_versioning_enabled(s3_bucket_versioning__changes)
}
########################################################
# Baseline: S3 - Bucket should block all public access #
########################################################
s3__public_access_disabled(s3_bucket_public_access__changes) if {
s3_bucket_public_access__changes[_].change.after.block_public_acls == true
s3_bucket_public_access__changes[_].change.after.block_public_policy == true
s3_bucket_public_access__changes[_].change.after.ignore_public_acls == true
s3_bucket_public_access__changes[_].change.after.restrict_public_buckets == true
}
#############################################
# Baseline: S3 - Bucket should be encrypted #
#############################################
s3__bucket_cryptography_enabled(s3_bucket_server_side_encryption_configuration__changes) if {
s3_bucket_server_side_encryption_configuration__changes[_].change.after.rule[_].apply_server_side_encryption_by_default[_].sse_algorithm == "AES256"
}
s3__bucket_cryptography_enabled(s3_bucket_server_side_encryption_configuration__changes) if {
s3_bucket_server_side_encryption_configuration__changes[_].change.after.rule[_].apply_server_side_encryption_by_default[_].sse_algorithm == "aws:kms"
s3_bucket_server_side_encryption_configuration__changes[_].change.after.rule[_].apply_server_side_encryption_by_default[_].kms_master_key_id != ""
}
s3__bucket_cryptography_enabled(s3_bucket_server_side_encryption_configuration__changes) if {
s3_bucket_server_side_encryption_configuration__changes[_].change.after.rule[_].apply_server_side_encryption_by_default[_].sse_algorithm == "aws:kms:dsse"
s3_bucket_server_side_encryption_configuration__changes[_].change.after.rule[_].apply_server_side_encryption_by_default[_].kms_master_key_id != ""
}
#############################################
# Baseline: S3 - Bucket should be versioned #
#############################################
s3__bucket_versioning_enabled(s3_bucket_versioning__changes) if {
lower(s3_bucket_versioning__changes[_].change.after.versioning_configuration[_].status) == "enabled"
}
Com a política escrita, agora precisamos integrar ao workflow.
Integração com o workflow
O próximo passo é integrar as políticas criadas no passo anterior com o workflow do Github Actions e forçar a falha caso alguma das políticas não seja atendida. Para isso, precisamos primeiramente realizar a instalação do OPA no workflow, portanto, vamos utilizar o OPA Setup Github Action, conforme abaixo:
1
2
3
4
- name: Setup OPA
uses: open-policy-agent/setup-opa@v2
with:
version: latest
Em seguida a ideia é obter o resultado enviado pela política de autorização, interpretar e falhar a pipeline caso a política sinalize, conforme a seguir:
1
2
3
4
5
6
7
8
9
10
11
12
- name: Terraform Authz
run: |
RESULT=`opa exec --decision main/result --bundle ../policy/ tfplan.json`
ALLOWED=`echo $RESULT | jq -r '.result[0].result.allowed'`
VIOLATIONS=`echo $RESULT | jq -r '.result[0].result.violations'`
if [ "$ALLOWED" == "true" ]; then
echo "Terraform authz success"
else
echo "Terraform authz failed"
echo "Security violations: $VIOLATIONS"
exit 1
fi
Validando a solução
Primeiramente, vamos realizar o teste quebrando alguma regra da baseline de segurança. Para isto iremos manipular o arquivo main.tf quebrando as regras a seguir:
- Necessidade de garantir que os buckets sejam privados
- Necessidade de garantir que os buckets tenham versionamento habilitado
Portanto o código ficaria desta maneira:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
resource "aws_s3_bucket" "this" {
bucket = "my-tf-test-bucket-${random_id.this.hex}"
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Disabled" # quebrando a baseline
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = false # quebrando a baseline
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "random_id" "this" {
byte_length = 8
}
A execução da pipeline se dará desta maneira:
Note que a policy executou, identificou problemas de autorização no plano do terraform e impediu que a esteira continuasse com a criação dos recursos. Caso os recursos de acesso público e versionamento não fossem declarados, a pipeline também iria falhar.
Por fim, vamos testar a autorização de IaC com sucesso, para isso, vamos corrigir os problemas de segurança no arquivo main.tf
e executar a pipeline novamente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
resource "aws_s3_bucket" "this" {
bucket = "my-tf-test-bucket-${random_id.this.hex}"
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled" # corrigir problema de segurança
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true # corrigir problema de segurança
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "random_id" "this" {
byte_length = 8
}
A execução da pipeline se dará desta maneira:
Conclusão
Neste artigo foi apresentado como é possível autorizar infraestrutura como código utilizando o OPA e Github Actions. A solução abordada tem um investimento de tempo relativamente baixo, desta maneira, tem um ótimo custo-benefício para empresas que estão iniciando ou querem iniciar a jornada de autorização de infraestrutura como código.