Três statements, cada um com Effect Allow, Resource específico e nada de Action: "*". Revisado statement a statement, tudo parece razoável: passar uma role para uma função Lambda, criar a função, invocar a função. O problema é que essas três permissões juntas formam um caminho de escalonamento de privilégio conhecido — o principal cria uma Lambda nova com a role passada e a invoca, herdando o que quer que aquela role possa fazer. Nenhuma das três statements isoladas é suspeita. A combinação é.
Um revisor humano lendo o JSON de cima para baixo dificilmente vai montar essa equação de cabeça, principalmente quando as três statements não estão nem uma do lado da outra. É exatamente o tipo de achado que dá para automatizar antes do deploy, sem precisar de conta AWS, credencial ou Access Analyzer rodando contra um ambiente real. É isso que o Parliament faz.
A política e o achado
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PassExistingRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/worker"
},
{
"Sid": "CreateWorkerFunction",
"Effect": "Allow",
"Action": "lambda:CreateFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:worker"
},
{
"Sid": "InvokeWorkerFunction",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:worker"
}
]
}
O Parliament é um linter de políticas IAM que roda inteiramente local — sem chamada de API, sem STS, sem nada. Testado aqui na versão 1.6.4, instalada via PyPI. O binário instalado se chama parliament, não parliament-cli — é fácil errar isso de cabeça se você já usou outra ferramenta com nome parecido.
Versão e o flag que vem desligado
$ parliament --version
parliament 1.6.4
A parte que pega gente de surpresa: os auditores da comunidade — inclusive o de privilege escalation, que é o que interessa aqui — vêm desligados por padrão. Sem --include-community-auditors, rodar o Parliament contra a política acima não produz nenhuma saída e sai com código 0:
$ parliament --files extracted-bad-policy.json --json
$ echo $?
0
Silêncio total. Um gate de CI que esquece esse flag está apenas validando sintaxe JSON, não escalonamento de privilégio. Com o flag ligado, o mesmo arquivo produz dois achados:
$ parliament --files extracted-bad-policy.json --include-community-auditors --json
{"issue": "PERMISSIONS_MANAGEMENT_ACTIONS", "title": "Permissions management actions", "severity": "MEDIUM", "description": "Allows the principal to modify IAM, RAM, identity-based policies, or resource based policies.", "detail": "", "location": {"actions": ["iam:passrole"], "filepath": "extracted-bad-policy.json"}}
{"issue": "PRIVILEGE_ESCALATION", "title": "Privilege escalation", "severity": "HIGH", "description": "Actions contain a combination of Privilege Escalation actions established by Rhino Security Labs", "detail": "", "location": {"type": "PassExistingRoleToNewLambdaThenInvoke", "actions": ["iam:passrole", "lambda:createfunction", "lambda:invokefunction"], "filepath": "extracted-bad-policy.json"}}
O segundo achado é o que interessa: PRIVILEGE_ESCALATION, severidade HIGH, location.type igual a PassExistingRoleToNewLambdaThenInvoke, com as três actions listadas. O detail vem vazio — a explicação real está no location.type, que é o nome do método de escalonamento. E repare que o id do issue (PRIVILEGE_ESCALATION) só aparece na saída --json; no modo texto padrão você vê título, severidade e descrição, mas não esse identificador — o que importa se você quiser filtrar achados por tipo num script.
O primeiro achado, PERMISSIONS_MANAGEMENT_ACTIONS, é outra coisa: o mesmo flag liga um auditor separado que sinaliza qualquer iam:PassRole sozinho como ação de gerenciamento de permissões, severidade MEDIUM. Não confunda os dois — é o PRIVILEGE_ESCALATION que prova a combinação perigosa; o MEDIUM é ruído correlato, não o achado em si.
O auditor de privilege escalation instalado nesta versão tem 22 métodos cadastrados em escalation_methods (PassExistingRoleToNewLambdaThenInvoke é um deles). A descrição do achado credita a lista à Rhino Security Labs, e o post da Rhino é a referência que esse texto nomeia — não uma API do Parliament, só a fonte que a ferramenta cita.
Extraia antes de lintar
O Parliament lê políticas IAM, não templates CloudFormation. Apontar ele direto para o template.json falha, e falha de um jeito que não ajuda muito a debugar:
$ parliament --files template.json --include-community-auditors --json
{"issue": "MALFORMED", "title": "Malformed", "severity": "HIGH", "description": "Policy does not contain a required element", "detail": "Policy contains an unknown element", "location": {"string": "AWSTemplateFormatVersion", "lineno": 2, "column": 31, "filepath": "template.json"}}
Ele está tentando interpretar AWSTemplateFormatVersion como se fosse campo de uma política IAM. Faz sentido — o Parliament não sabe nada sobre a estrutura de um template CloudFormation. O passo que falta é extrair o PolicyDocument de dentro do template antes de lintar. Aqui isso foi feito com um script local que varre Resources, pega Properties.Policies[].PolicyDocument de todo AWS::IAM::Role e o PolicyDocument de AWS::IAM::Policy, AWS::IAM::ManagedPolicy e AWS::IAM::RolePolicy, e grava cada um como um JSON de política solto. (Ele não extrai AssumeRolePolicyDocument — essa é a trust policy, outro assunto.) O resultado bateu byte a byte com as políticas de referência do fixture — o extrator não inventou nem perdeu nada no caminho.
O que o Guard ainda pega, e o que essa regra local não pega
O post anterior sobre CloudFormation Guard mostrou uma regra org-wide rodando contra o template inteiro, sem conta AWS. Vale contrastar os dois, porque resolvem problemas diferentes. Rodando aqui uma regra local só de wildcard (não o org.guard completo daquele artigo) contra três variações do mesmo template:
| Fixture | Parliament (política extraída) | Guard (regra wildcard-only) |
|---|---|---|
Actions nomeadas, sem * | PRIVILEGE_ESCALATION HIGH — exit 1 | PASS — exit 0 |
iam:Pass* + lambda:* (wildcard dividido em duas statements) | PRIVILEGE_ESCALATION HIGH — exit 1 | PASS — exit 0 |
Uma statement com Action: "*" | (não testado neste fixture) | FAIL — exit 19 |
Isso não quer dizer que o Guard seja cego para wildcard — ele pegou o Action: "*" direto, saindo com 19. O que ele não pega é a combinação de actions nomeadas, nem o wildcard fatiado em duas statements diferentes (iam:Pass* numa, lambda:* noutra): nenhuma das duas isoladamente bate com a regra "Action é exatamente * ou s3:*", então o Guard passa as duas. O Parliament, que olha o conjunto de actions permitidas depois de expandir os wildcards, pegou os dois casos. São ferramentas que auditam coisas diferentes: o Guard valida propriedades do recurso do template contra uma regra; o Parliament entende semântica de IAM e sabe que passar uma role para uma Lambda nova mais invocá-la é um método de escalonamento catalogado, não importa como as actions estão distribuídas entre statements.
O gate: exit code, HIGH vs. CRITICAL, e a armadilha do InvokeFunction que falta
Um gate de CI simples só precisa propagar o exit code do Parliament:
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
POLICY=${1:?policy json path}
shift
exec "$ROOT/venv/bin/parliament" \
--files "$POLICY" \
--include-community-auditors \
--json \
"$@"
Repare no --files, plural, não --file. Em CI, sem TTY, --file explode:
$ parliament --file bad-policy.json --include-community-auditors --json
parliament: error: You cannot pass a file with --file and use stdin together
$ echo $?
2
--files não tem esse problema — foi o que o gate acima usou o tempo todo.
O cli.py retorna 1 se sobrar algum achado depois do filtro de severidade, e 0 se não sobrar nenhum. Erro de argumento do argparse — flag errado, valor fora da lista de escolhas — sai com 2, que é uma categoria de erro diferente de "a política tem um problema". O flag real de severidade é --minimum_severity, com as opções CRITICAL, HIGH, MEDIUM, LOW, INFO. Não existe --min-severity; passar isso dá unrecognized arguments e sai 2, não filtra nada:
$ parliament --files extracted-bad-policy.json --min-severity HIGH
parliament: error: unrecognized arguments: --min-severity HIGH
Rodando o gate sem filtro, os dois achados aparecem e o exit é 1 — o PERMISSIONS_MANAGEMENT_ACTIONS MEDIUM já é suficiente para reprovar o build. Com --minimum_severity HIGH, só o PRIVILEGE_ESCALATION sobra e o exit continua 1 — que é o comportamento que você quer num gate: isolar o achado grave do ruído de severidade menor. Com --minimum_severity CRITICAL, esse HIGH some do relatório e o gate sai 0. CRITICAL é severidade alta demais para esse achado específico — o Parliament classifica escalonamento de privilégio via essa cadeia como HIGH, não CRITICAL, e um gate configurado para CRITICAL deixa passar exatamente o caso que você queria pegar.
A armadilha prática mais comum é a variação da política que passa a role e cria a função, mas esquece o lambda:InvokeFunction. Sem a terceira permissão, o subset que o auditor de privesc procura não fecha, e PRIVILEGE_ESCALATION não dispara. O gate padrão (sem filtro de severidade) ainda falha, mas por causa do PERMISSIONS_MANAGEMENT_ACTIONS MEDIUM do iam:PassRole sozinho — não pelo motivo que você pensa. Em --minimum_severity HIGH, esse mesmo fixture sai 0: nada de HIGH sobrou, porque a combinação que dispara o HIGH nunca se formou. E vale lembrar de uma pegadinha na direção oposta: colocar as três actions numa única statement cujo Resource é só o ARN da role não faz lambda:CreateFunction e lambda:InvokeFunction virarem permitidos de fato — essas duas exigem um ARN de função Lambda. Nesse caso o Parliament não acha PRIVILEGE_ESCALATION; acha RESOURCE_MISMATCH.
Recomendação
A DevDojo colocaria essa checagem em CI sobre o JSON de política — standalone ou extraído de template — com --include-community-auditors e --minimum_severity HIGH. Não pede conta AWS e pega uma classe de erro que revisão manual statement por statement tende a deixar passar. Um gate configurado só para CRITICAL, ou uma regra Guard de "sem wildcard" sozinha, não é o mesmo controle: nenhum dos dois pega essa combinação específica de actions nomeadas.
Dito isso, o Parliament não substitui uma checagem contra a conta real. Ele não roda o IAM Access Analyzer, não sabe o que outras políticas ou SCPs no ambiente permitem, e a lista de 22 métodos de escalonamento é finita — uma combinação de actions fora dela não vai disparar nada. Trate como um portão antes do deploy, não como a palavra final sobre o que a role pode fazer em produção.
Próximo passo
Rode o fixture ruim e o fixture limpo lado a lado e confira os exit codes:
$ parliament --files extracted-bad-policy.json --include-community-auditors --json --minimum_severity HIGH; echo "exit=$?"
$ parliament --files extracted-clean-policy.json --include-community-auditors --json --minimum_severity HIGH; echo "exit=$?"
O primeiro deve sair 1 com o PRIVILEGE_ESCALATION no JSON. O segundo, que só tem um s3:GetObject restrito a um prefixo, deve sair 0. Se os dois baterem, o gate está lendo o achado certo — não só reagindo a qualquer coisa que apareça na saída.