Log sem senha: ache e corte dado sensível do log
Varra os logs atrás de token, CPF e cartão, veja onde o redact do pino falha, grave só campos escolhidos, limpe a query string do nginx e saiba o que fazer.
Quase todo projeto já tem alguma senha, token ou CPF gravado em log. Não por descuido de uma pessoa, mas porque o log nasce de um console.log(req.body) de depuração, de um access log que guarda a URL inteira ou de um erro que despeja o objeto da requisição. Por isso este guia começa pelo que já está em disco, e só depois passa para a configuração que evita o próximo vazamento.
1. Varra os logs que você já tem
Antes de mexer no logger, descubra o tamanho do problema. O script abaixo lê arquivos de log comuns e comprimidos (.gz, que é como o logrotate guarda os antigos) e procura JWT, cabeçalho Bearer, senha em texto, token em URL, chave de acesso da AWS, CPF, número de cartão e e-mail. CPF só conta se os dígitos verificadores baterem, e cartão só conta se passar no algoritmo de Luhn, o que corta muito falso positivo de número de pedido e código de barras.
#!/usr/bin/env python3
"""varrer_logs.py: procura segredo e dado pessoal em arquivos de log (inclusive .gz).
Uso: python3 varrer_logs.py /var/log/minha-app/*.log*"""
import gzip, re, sys
from collections import Counter
def cpf_valido(d):
d = re.sub(r"\D", "", d)
if len(d) != 11 or d == d[0] * 11:
return False
for n in (9, 10):
s = sum(int(d[i]) * (n + 1 - i) for i in range(n))
if (s * 10 % 11) % 10 != int(d[n]):
return False
return True
def luhn(d):
d = re.sub(r"\D", "", d)
if not 13 <= len(d) <= 19:
return False
tot = 0
for i, c in enumerate(reversed(d)):
x = int(c) * (2 if i % 2 else 1)
tot += x - 9 if x > 9 else x
return tot % 10 == 0
REGRAS = {
"jwt": (re.compile(r"eyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]{8,}"), None),
"bearer": (re.compile(r"(?i)authorization[\"':= ]+bearer\s+\S{12,}"), None),
"senha_em_texto": (re.compile(r"(?i)[\"']?(senha|password|passwd)[\"']?\s*[:=]\s*[\"']?[^\"'&\s,}]{3,}"), None),
"token_na_url": (re.compile(r"[?&](token|access_token|code|reset)=[^&\s\"]{8,}"), None),
"chave_aws": (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), None),
"cpf": (re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b"), cpf_valido),
"cartao": (re.compile(r"\b(?:\d[ -]?){13,19}\b"), luhn),
"email": (re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), None),
}
def abrir(caminho):
return gzip.open(caminho, "rt", errors="replace") if caminho.endswith(".gz") \
else open(caminho, errors="replace")
total = Counter()
for caminho in sys.argv[1:]:
with abrir(caminho) as f:
for n, linha in enumerate(f, 1):
for nome, (rx, conferir) in REGRAS.items():
for m in rx.finditer(linha):
if conferir and not conferir(m.group(0)):
continue
total[nome] += 1
if total[nome] <= 3: # mostra só o começo, nunca o valor inteiro
print(f"{caminho}:{n}: {nome}: {m.group(0)[:6]}…")
print("\nResumo:", dict(total) or "nada encontrado")
Rodando contra um log de teste com seis linhas, uma delas com um CPF de dígito errado (123.456.789-00) e um número de 13 dígitos qualquer, a saída fica assim:
app.log.1.gz:2: senha_em_texto: "senha…
app.log.1.gz:2: email: ana@em…
app.log.1.gz:3: token_na_url: ?token…
app.log.1.gz:4: jwt: eyJhbG…
app.log.1.gz:4: bearer: Author…
app.log.1.gz:5: cpf: 123.45…
app.log.1.gz:5: cartao: 4111 1…
Resumo: {'senha_em_texto': 1, 'email': 1, 'token_na_url': 1, 'jwt': 1, 'bearer': 1, 'cpf': 1, 'cartao': 1}
As duas armadilhas da última linha ficaram de fora, como deveriam. Repare também que o script imprime só os seis primeiros caracteres de cada achado: um relatório de varredura que repete o segredo inteiro vira mais um lugar onde ele vazou. Rode em /var/log do servidor, na pasta de logs da aplicação e, se for possível exportar, no serviço externo de logs.
2. O redact do pino esconde menos do que parece
Em Node.js, o redact do pino é a primeira defesa que muita gente configura. Ele funciona, mas cada caminho cobre exatamente o que está escrito. Este teste foi feito com o pino 10.4.0:
const pino = require('pino');
const log = pino({
redact: {
paths: [
'req.headers.authorization', 'req.headers.cookie',
'body.senha', 'body.cartao', '*.token', 'body.*.cpf'
],
censor: '[OCULTO]'
}
});
log.info({
req: { headers: { authorization: 'Bearer eyJhbGciOi...', cookie: 'sid=abc', 'user-agent': 'curl/8' } },
body: { email: 'ana@empresa.com.br', senha: 'hunter2', cartao: '4111111111111111',
titular: { cpf: '123.456.789-09' }, endereco: { cep: '01310-100', token: 'x' },
pagamento: { dados: { token: 'tok_live_9' } } }
}, 'pedido recebido');
Saída:
{"level":30,"req":{"headers":{"authorization":"[OCULTO]","cookie":"[OCULTO]","user-agent":"curl/8"}},"body":{"email":"ana@empresa.com.br","senha":"[OCULTO]","cartao":"[OCULTO]","titular":{"cpf":"[OCULTO]"},"endereco":{"cep":"01310-100","token":"x"},"pagamento":{"dados":{"token":"tok_live_9"}}},"msg":"pedido recebido"}
Três coisas passaram. O *.token pega só o filho direto de uma chave do topo (body.token, req.token), então body.endereco.token e body.pagamento.dados.token saíram em texto puro. O e-mail saiu porque ninguém listou. E a documentação de redaction avisa que os caminhos diferenciam maiúsculas: em Node os cabeçalhos chegam em minúsculas, mas um campo Senha vindo de outro sistema escapa de body.senha.
Lista de esconder é uma lista do que você lembrou. Campo novo no formulário, integração nova com outro formato, e o vazamento volta sem ninguém perceber.
3. Escolha o que entra, em vez de listar o que sai
A alternativa é inverter a lógica: o log recebe só os campos que ajudam a diagnosticar, e o resto aparece apenas pelo nome. O pino permite isso com um serializer para a chave body:
const pino = require('pino');
// Só estes campos do corpo podem ir para o log; o resto vira a lista de chaves.
const PERMITIDOS = new Set(['pedidoId', 'itens', 'formaPagamento']);
function corpoSeguro(body = {}) {
const saida = {};
for (const [chave, valor] of Object.entries(body)) {
if (PERMITIDOS.has(chave)) saida[chave] = valor;
}
saida.camposOmitidos = Object.keys(body).filter((c) => !PERMITIDOS.has(c));
return saida;
}
const log = pino({ serializers: { body: corpoSeguro } });
log.info({
body: { pedidoId: 8812, itens: 3, formaPagamento: 'pix',
email: 'ana@empresa.com.br', titular: { cpf: '123.456.789-09' },
pagamento: { dados: { token: 'tok_live_9' } } }
}, 'pedido recebido');
{"level":30,"body":{"pedidoId":8812,"itens":3,"formaPagamento":"pix","camposOmitidos":["email","titular","pagamento"]},"msg":"pedido recebido"}
Quem investiga um problema continua sabendo que o pedido 8812 tinha 3 itens no Pix e que o corpo trazia email, titular e pagamento. Só não tem os valores. Campo novo começa fora do log até alguém decidir que ele precisa entrar. A folha de referência de logging da OWASP recomenda essa postura: senhas, tokens de sessão e de acesso, chaves e dados pessoais sensíveis não devem ir para o log, e quando um identificador for necessário, prefira uma versão mascarada ou com hash.
Para seguir uma requisição de ponta a ponta sem gravar quem é o usuário, use um identificador de requisição, como explicado no guia de correlation id. E vale separar a ferramenta da decisão: trocar de stack de logs não resolve nada se a aplicação continua mandando o corpo inteiro, ponto que o post sobre o que logar antes de escolher ferramenta desenvolve.
4. A query string vai parar no access log
Link de redefinição de senha, código de OAuth e token de convite costumam viajar na URL. O formato combined, padrão do nginx, grava a variável $request, que é a linha original da requisição com a query string inteira. Pelo módulo core do nginx, a variável $uri traz o caminho sem os argumentos, então dá para montar um formato próprio com o log_format:
# /etc/nginx/conf.d/log-sem-query.conf
log_format sem_query '$remote_addr - [$time_iso8601] "$request_method $uri $server_protocol" '
'$status $body_bytes_sent $request_time "$http_user_agent"';
server {
# ...
access_log /var/log/nginx/loja.access.log sem_query;
}
O custo é perder parâmetros úteis como ?pagina=2. Se eles fizerem falta, registre os que interessam um por um ($arg_pagina) em vez de voltar para a URL inteira. Lembre que proxy reverso, balanceador e CDN na frente do servidor têm logs próprios, com a mesma questão. E a correção definitiva é tirar o segredo da URL quando der: token de redefinição que vale uma vez e por poucos minutos já reduz muito o estrago.
5. Achou segredo no log: a ordem importa
- Troque o segredo primeiro. Senha, token de API ou chave que apareceu em log deve ser considerada exposta. Apagar a linha não desfaz quem já leu, nem a cópia no backup. É a mesma lógica de segredo que foi parar no Git.
- Corrija a origem (serializer, formato do nginx, o
console.logesquecido) e confirme com o script da seção 1 que linhas novas saem limpas. - Limpe o histórico: arquivos rotacionados, backups de disco, o serviço externo de logs e qualquer exportação que alguém tenha baixado.
- Avalie a comunicação. Se o log expôs dados pessoais e o incidente pode gerar risco ou dano relevante aos titulares, a página de comunicação de incidente da ANPD lembra que a Resolução CD/ANPD nº 15/2024 dá ao controlador três dias úteis para comunicar a ANPD e os titulares.
6. Guarde por menos tempo
Log que não existe não vaza. A LGPD traz no artigo 6º o princípio da necessidade: tratar só o mínimo de dados para a finalidade. Para log de diagnóstico, duas semanas costumam cobrir a investigação de quase todo problema. Uma configuração de logrotate nessa linha:
/var/log/loja/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 loja adm
}
O create 0640 limita a leitura ao dono e ao grupo, o que evita que qualquer usuário do servidor abra os arquivos. Se algum registro precisa durar mais, por obrigação legal ou auditoria, separe esse log dos de diagnóstico e dê a ele um prazo explícito, como no guia de retenção com data de expiração.
Coloque o script de varredura num cron semanal ou no pipeline de deploy, apontando para os logs do ambiente de homologação. Ele não substitui escolher bem os campos, mas avisa cedo quando algum caminho novo voltou a gravar o que não devia.
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html