Skip to content
burnoutscriptPublic

About

Auditor de políticas Row Level Security para PostgreSQL e Supabase, com SQL de correção sugerido

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

Repository files navigation

rls-guard

Auditor de políticas Row Level Security para PostgreSQL e Supabase.

Você passa o schema (arquivo .sql ou conexão com o banco) e ele aponta onde um usuário consegue ler ou escrever dado de outro usuário, com o SQL de correção sugerido.

CI Python Dependências Licença

Por que fiz isso

Num app Supabase o front conversa direto com o banco usando a chave anon, que fica no bundle do cliente. Quem decide o que cada usuário enxerga é o RLS. Uma política com USING (true) no lugar errado e qualquer pessoa com o DevTools aberto lê a tabela de usuários inteira.

Configurei RLS em todas as tabelas da Prumo, e o que mais deu trabalho foi revisar as políticas. auth.role() = 'authenticated' parece controle de acesso e libera tudo para qualquer usuário logado. Uma view criada pelo SQL Editor ignora o RLS das tabelas sem avisar. Transformei esses erros em regras que rodam no CI, antes da migration ir para produção.

Instalação

git clone https://github.com/burnoutscript/rls-guard.git
cd rls-guard
pip install -e .

Python 3.10+. O modo padrão (ler arquivo SQL) não tem dependência nenhuma. O modo de conexão com o banco usa psycopg e é opcional:

pip install -e ".[live]"

Uso

# saída do pg_dump
pg_dump --schema-only "$DATABASE_URL" > schema.sql
rls-guard schema.sql

# migrations do Supabase, aplicadas na ordem
rls-guard supabase/migrations/*.sql

# com o SQL de correção em cada achado
rls-guard schema.sql --fix

# Markdown para colar no pull request
rls-guard supabase/migrations/*.sql --format markdown --fix -o relatorio.md

# JSON
rls-guard schema.sql --format json | jq '.findings[] | select(.severity=="critical")'

# banco ao vivo (só leitura, consulta apenas o catálogo)
rls-guard --dsn "postgresql://auditor@localhost:5432/app"

# outros esquemas, ignorando uma regra
rls-guard schema.sql --schema public --schema api --ignore RG003

Trecho da saída com --fix:

  rls-guard — auditoria de Row Level Security
  6 tabelas · 5 com RLS · 8 políticas · 10 achado(s)
  ──────────────────────────────────────────────────────────────────

   CRÍTICO  Leitura liberada para anônimos  RG002
  public.usuarios · política "perfis visiveis"   samples/schema_inseguro.sql:23

  A política libera SELECT com USING (true) para anon, authenticated. Qualquer pessoa, sem
  login, lê a tabela inteira.

  → Restringir a política ao role authenticated e ao dono da linha. Se o dado for público
  de propósito, expor só as colunas necessárias por uma view com security_invoker.

    DROP POLICY "perfis visiveis" ON public.usuarios;
    CREATE POLICY "perfis visiveis" ON public.usuarios FOR SELECT TO authenticated
      USING ((select auth.uid()) = id);

   CRÍTICO  Escrita liberada por condição permissiva  RG004
  public.redacoes · política "aluno atualiza redacao"   samples/schema_inseguro.sql:56

  A condição de UPDATE (auth.role() = 'authenticated') não amarra a linha a ninguém:
  qualquer usuário que a política alcança escreve em qualquer linha.

Os dois schemas de exemplo imitam uma plataforma de estudos (usuarios, redacoes, assinaturas, questoes):

rls-guard samples/schema_inseguro.sql --fix   # 10 achados, pelo menos um por regra
rls-guard samples/schema_seguro.sql           # o mesmo schema corrigido, nenhum achado

Regras

ID Regra Severidade O que procura
RG001 Tabela sem RLS Crítica / Alta Tabela sem ENABLE ROW LEVEL SECURITY. Crítica se tem GRANT para anon ou authenticated
RG002 Leitura pública Crítica Política de SELECT com USING (true) que alcança anon (ou PUBLIC, que inclui anon)
RG003 RLS sem política Baixa RLS ligado e nenhuma política: tudo fica negado, quase sempre por esquecimento
RG004 Escrita permissiva Crítica INSERT/UPDATE/DELETE com condição que não amarra a linha a ninguém
RG005 Sem WITH CHECK Alta / Baixa Política de escrita que não valida a linha nova (ver abaixo)
RG006 Dado sensível sem RLS Crítica Coluna como cpf, email, senha, token, api_key, cartao em tabela sem RLS
RG007 View contorna RLS Alta View sem security_invoker = on, ou materializada, sobre tabela com RLS
RG008 SECURITY DEFINER sem search_path Alta Função SECURITY DEFINER sem SET search_path
RG009 Escrita para anon Crítica GRANT ALL ou privilégio de escrita para anon ou PUBLIC

A RG004 não procura só true. Antes de testar, ela simplifica a condição:

Condição Resultado
true, 1 = 1, x OR true permissiva (sempre verdadeira)
auth.role() = 'authenticated' permissiva (qualquer usuário logado passa, em qualquer linha)
auth.uid() IS NOT NULL permissiva (mesma coisa com outra cara)
(select auth.uid()) = usuario_id restrita
auth.uid() IS NOT NULL AND auth.uid() = usuario_id restrita (a segunda parte restringe)

As linhas 2 e 3 são o erro que mais vejo em projeto Supabase. A política exige login, passa no teste manual, e cada aluno consegue editar a redação de todos os outros.

Decisão de projeto: UPDATE sem WITH CHECK é Baixa, INSERT é Alta

O peso de uma política de escrita sem WITH CHECK depende do comando, por causa do que o Postgres faz quando a cláusula falta:

  • FOR INSERT sem WITH CHECK: INSERT não tem USING, então nada valida a linha nova. O aluno consegue inserir uma redação com o usuario_id de outra pessoa. Alta.
  • FOR UPDATE ou FOR ALL sem WITH CHECK: o Postgres usa o próprio USING para validar a linha nova. Se o USING é auth.uid() = usuario_id, a linha atualizada também precisa satisfazer isso, e o aluno não consegue passar a redação para o nome de outra pessoa. A proteção existe, só que implícita. Baixa, com a sugestão de deixar explícito.

Se as duas fossem Alta, um schema correto sairia com achado grave, e em pouco tempo ninguém mais leria o relatório. Quando o USING é ele mesmo permissivo, quem aponta o problema é a RG004 (crítica), e a RG005 não repete o achado.

A RG003 segue a mesma lógica. RLS ligado sem política nega todo acesso, então o erro deixa a tabela inacessível em vez de exposta, e fica como Baixa.

Arquitetura

  schema.sql / migrations        banco ao vivo (--dsn)
          │                              │
          ▼                              ▼
    ┌───────────┐                  ┌──────────┐
    │  parser   │  divide em       │   live   │  consulta pg_class,
    │           │  comandos e      │          │  pg_policies, pg_proc
    └─────┬─────┘  aplica em ordem └────┬─────┘  (somente leitura)
          │                              │
          └──────────────┬───────────────┘
                         │  Schema  (tabelas, políticas, grants, views, funções)
                         ▼
                   ┌──────────┐   9 regras independentes
                   │  rules   │   recebem o modelo, devolvem achados
                   └────┬─────┘
                        │  Finding  (severidade, objeto, linha, SQL de correção)
                        ▼
                   ┌──────────┐        ┌─────────┐
                   │  report  │◀───────│  fixes  │  SQL de remediação (--fix)
                   └────┬─────┘        └─────────┘
                        │
              terminal · JSON · Markdown

O parser aplica os comandos na ordem em que aparecem, do jeito que o banco faria. Um DISABLE depois do ENABLE ROW LEVEL SECURITY desliga o RLS de novo, um GRANT ... ON ALL TABLES IN SCHEMA só pega as tabelas que já existiam naquele ponto, e um REVOKE depois de GRANT ALL tira os privilégios.

As regras não leem SQL, só o modelo Schema. Por isso o modo ao vivo, que lê o catálogo do Postgres em vez de arquivo, usa as mesmas nove regras sem código extra.

O --fix só imprime o SQL; nada é executado no banco. A política sugerida amarra a linha ao auth.uid() usando a coluna de dono que encontrar (usuario_id, user_id, owner_id...). Isso serve para dado do próprio usuário. Numa tabela compartilhada entre membros de uma turma a política teria que ser outra, por isso o SQL precisa de revisão antes de rodar. Ele usa (select auth.uid()) em vez de auth.uid() porque, com o select, o Postgres avalia a função uma vez por consulta e não uma vez por linha.

Código de saída

Código Significado
0 Nenhum achado
1 Achados encontrados
2 Erro (arquivo não existe, nenhuma tabela, regra desconhecida, banco inacessível)

No CI:

- run: |
    pip install git+https://github.com/burnoutscript/rls-guard.git
    rls-guard supabase/migrations/*.sql --min-severity high

Desenvolvimento

pip install -e ".[dev]"
pytest -v        # 83 testes
ruff check src tests

Cada regra tem teste nos dois sentidos: o caso que deve disparar e um parecido que não deve (política restritiva com USING (true), SELECT liberado só para authenticated, view sobre tabela sem RLS, coluna tokens_usados).

Limitações

  • O parser não cobre todo o PostgreSQL, só os comandos que as regras usam. O resto do arquivo é ignorado, e SQL dinâmico dentro de DO $$ ... $$ não é interpretado.
  • A análise de expressão é sintática. USING (eh_admin()) não é avaliado (não tem como saber o que a função devolve) e conta como restrita. Tautologias mais elaboradas que x OR true passam.
  • Dado sensível é detectado pelo nome da coluna. documento com CPF dentro passa. O nome é quebrado por _, então tokens_usados não dispara, mas email_marketing_opt_in dispara mesmo sem guardar e-mail.
  • O Supabase concede ALL para anon por padrão nas tabelas do public (via ALTER DEFAULT PRIVILEGES). A RG009 aponta isso de propósito, porque aí o RLS vira a única barreira. Se foi uma escolha consciente, use --ignore RG009.
  • A RG002 não cruza com os GRANTs: uma política USING (true) para anon aparece mesmo que anon não tenha SELECT na tabela. Ela vira problema no primeiro GRANT que alguém fizer.
  • O modo ao vivo foi escrito para o catálogo do Postgres 15+ e é testado com linhas simuladas. O modo por arquivo é o principal e é o que o CI cobre.

Licença

MIT. Veja LICENSE.

About

Auditor de políticas Row Level Security para PostgreSQL e Supabase, com SQL de correção sugerido

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages