Blog

Anonimização de dados antes de mandar para o LLM

O prompt que sai do seu servidor carrega tudo que estava na conversa: o CPF que o cliente digitou para se identificar, o cartão que ele colou por engano no chat, o endereço completo de entrega, o e-mail corporativo de um terceiro que nem é seu cliente. Esse texto atravessa a internet, é processado por um provedor externo e frequentemente fica retido em log de erro, em cache de prompt, em amostra de monitoramento. Nenhuma dessas retenções é maliciosa, e todas são superfície de exposição que o seu contrato com o cliente não previu. Anonimizar antes de mandar não é burocracia de conformidade, é reduzir o dado em trânsito ao mínimo que a tarefa exige: o modelo precisa saber que existe um número de pedido, não precisa saber qual CPF está por trás dele. Este artigo mostra como construir essa camada de forma reversível e testável: o que realmente precisa sair, como detectar sem depender só de expressão regular, por que o marcador precisa preservar formato e ser estável dentro da conversa, como reidratar a resposta sem vazar o mapa e como provar em teste que nenhum dado real escapou.

2026-07-27 / IA Aplicada / 14 min

01

O que sai no prompt é maior do que você imagina

A conta de exposição raramente é feita, e quando é, costuma parar na mensagem atual do usuário. Só que o payload que chega ao provedor é bem maior: o histórico da conversa inteira, os trechos recuperados do RAG, o resultado de cada tool call, o system prompt com regras de negócio, e às vezes um bloco de contexto do CRM que alguém injetou para o bot "conhecer o cliente". Cada uma dessas fontes carrega dado pessoal por caminhos diferentes. O histórico traz o que o cliente digitou. O RAG traz o que estava no documento indexado, incluindo o e-mail do autor que ninguém lembrou de tirar. A tool call que consulta o pedido devolve nome, telefone e endereço completos porque a API interna sempre devolveu assim.

O risco não é só o provedor: é a cadeia inteira que toca esse texto. O log de erro da sua própria aplicação grava o prompt quando a chamada falha, e esse log vai para um agregador de terceiros. A ferramenta de observabilidade guarda uma amostra dos prompts para você inspecionar qualidade. O cache de prompt do provedor retém o prefixo por um tempo para baratear a chamada seguinte. A base de eval que você montou a partir de conversas reais virou um arquivo no repositório. Nenhum desses lugares foi projetado como cofre, e todos passam a conter dado pessoal no instante em que ele entra no prompt. Anonimizar na borda de saída corta todos de uma vez, porque o que nunca esteve no texto não pode vazar em lugar nenhum depois.

Fonte do dadoO que costuma vazarPor que passa despercebido
Mensagem do usuárioCPF, cartão, telefone, e-mail colados no chatÉ o único ponto que a maioria dos times lembra de filtrar
Histórico da conversaTudo que já foi dito, reenviado a cada turnoO dado sai uma vez e volta a sair em todas as chamadas seguintes
Retorno de tool callNome completo, endereço, documento vindos da API internaVem do seu próprio backend, então parece confiável por definição
Trecho recuperado do RAGE-mail e nome de terceiros dentro do documento indexadoNinguém revisa PII no corpus na hora de indexar
Log e amostra de evalO prompt inteiro, já montado, salvo fora do sistemaÉ efeito colateral de debugar, não uma decisão consciente

02

Detectar: regex sozinha erra dos dois lados

A primeira tentativa é sempre uma lista de expressões regulares, e ela falha nas duas direções. Falso positivo: uma sequência de onze dígitos vira CPF, mas era um número de protocolo, e o marcador destrói justamente a informação que o modelo precisava para responder. Falso negativo: o cliente escreve o CPF com pontos e traço numa formatação que a regex não previu, ou separa os dígitos com espaço, ou escreve o cartão em quatro grupos com hífen. Regex é boa para achar candidatos e péssima para decidir se o candidato é de verdade. A correção é separar as duas etapas: a expressão encontra a forma, e um validador específico do tipo confirma o conteúdo. CPF tem dígito verificador. Cartão passa no algoritmo de Luhn. CNPJ tem checksum. Um candidato que não passa no validador não é anonimizado, e com isso o falso positivo cai drasticamente sem perder recall.

Nem todo dado pessoal tem estrutura verificável, e é aí que a detecção puramente léxica encontra seu teto. Nome próprio, endereço em texto corrido e apelido não têm dígito verificador nem formato fixo. Para esses, existem duas saídas que se combinam bem. A primeira é a âncora de contexto: quando o dado vem de um campo conhecido, o retorno de uma tool call que sabidamente traz o nome do titular, você não precisa detectar nada, você já sabe onde ele está e anonimiza por origem, não por conteúdo. A segunda é reduzir o texto livre a categorias: em vez de tentar identificar cada nome no histórico, envie a mensagem do cliente e mantenha o resto do dossiê fora do prompt, deixando o modelo pedir o que precisar via tool. O que não entra no prompt não precisa ser detectado.

// detect.js
// Regex acha o CANDIDATO; o validador do tipo decide se e real.
// Sem essa separacao, protocolo de 11 digitos vira CPF e o modelo
// perde a informacao que precisava para responder.

const PATTERNS = [
  { type: 'CPF', re: /\b\d{3}[.\s]?\d{3}[.\s]?\d{3}[-\s]?\d{2}\b/g, validate: isCpf },
  { type: 'CARD', re: /\b(?:\d[ -]?){13,19}\b/g, validate: isLuhn },
  { type: 'EMAIL', re: /\b[\w.+-]+@[\w-]+\.[\w.-]{2,}\b/g, validate: () => true },
  { type: 'PHONE', re: /\b(?:\+55\s?)?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}\b/g, validate: () => true },
];

function isCpf(raw) {
  const d = raw.replace(/\D/g, '');
  if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false;
  const check = (len) => {
    let sum = 0;
    for (let i = 0; i < len; i += 1) sum += Number(d[i]) * (len + 1 - i);
    const mod = (sum * 10) % 11;
    return (mod === 10 ? 0 : mod) === Number(d[len]);
  };
  return check(9) && check(10);
}

function isLuhn(raw) {
  const d = raw.replace(/\D/g, '');
  if (d.length < 13 || d.length > 19) return false;
  let sum = 0;
  let double = false;
  for (let i = d.length - 1; i >= 0; i -= 1) {
    let n = Number(d[i]);
    if (double) { n *= 2; if (n > 9) n -= 9; }
    sum += n;
    double = !double;
  }
  return sum % 10 === 0;
}

// Devolve os achados REAIS, ja filtrados pelo validador.
export function detect(text) {
  const found = [];
  for (const { type, re, validate } of PATTERNS) {
    for (const match of text.matchAll(re)) {
      if (validate(match[0])) found.push({ type, value: match[0], index: match.index });
    }
  }
  // Maior primeiro: evita que um trecho curto corte um achado maior.
  return found.sort((a, b) => b.value.length - a.value.length);
}

03

O marcador precisa preservar formato e ser estável

Substituir o dado por uma tarja opaca resolve o vazamento e cria dois problemas novos. O primeiro é de formato: se todo dado vira a mesma string genérica, o modelo perde a noção do tipo e passa a tratar um telefone e um documento como a mesma coisa, o que degrada a resposta em tarefas que dependem da estrutura. O marcador precisa dizer o tipo sem dizer o valor, algo como uma etiqueta tipada e numerada. O segundo problema é de identidade: se a mesma pessoa aparece três vezes na conversa e cada ocorrência vira um marcador diferente, o modelo enxerga três pessoas distintas e a coerência da resposta desmorona. O marcador precisa ser estável dentro do escopo da conversa, isto é, o mesmo valor de entrada mapeia sempre para a mesma etiqueta enquanto aquela sessão durar.

Essa estabilidade tem um limite que é fácil de ultrapassar sem perceber. Se o marcador for derivado de um hash determinístico do valor, e esse hash for o mesmo entre sessões e entre clientes, ele vira um pseudônimo persistente: quem tiver acesso aos logs consegue correlacionar todas as conversas do mesmo CPF ao longo do tempo, mesmo sem nunca ver o número. Isso deixa de ser anonimização e vira pseudonimização, que continua sendo dado pessoal para efeito regulatório. A escolha certa depende do objetivo: se você precisa correlacionar, use um hash com sal por tenant e trate o resultado como dado sensível; se não precisa, gere o marcador a partir de um contador por requisição, que morre com ela e não correlaciona nada. Na dúvida, o contador é a opção mais defensável.

Estratégia de marcadorCorrelaciona entre sessõesQuando usar
Contador por requisiçãoNão, o mapa morre com a requisiçãoPadrão: máxima proteção, o modelo só precisa de coerência interna
Hash com sal por tenantSim, dentro do tenantQuando análise precisa ligar conversas do mesmo cliente sem ver o dado
Hash global sem salSim, entre tenants e para sempreNunca: é pseudônimo permanente e ainda é dado pessoal
Tarja genérica sem tipoNão, mas destrói a estruturaSó quando a resposta não depende de distinguir os campos
// redact.js
// Marcador TIPADO (o modelo sabe que e um CPF) e ESTAVEL dentro da
// requisicao (a mesma pessoa nao vira tres pessoas diferentes).
// O mapa de volta vive so na memoria desta requisicao.

import { detect } from './detect.js';

export function createRedactionScope() {
  const toPlaceholder = new Map(); // valor real -> marcador
  const toValue = new Map();       // marcador -> valor real
  const counters = new Map();      // tipo -> proximo numero

  function placeholderFor(type, value) {
    const existing = toPlaceholder.get(value);
    if (existing) return existing; // estabilidade: mesmo valor, mesma etiqueta
    const n = (counters.get(type) || 0) + 1;
    counters.set(type, n);
    const token = '[' + type + '_' + n + ']';
    toPlaceholder.set(value, token);
    toValue.set(token, value);
    return token;
  }

  return {
    redact(text) {
      let out = text;
      for (const { type, value } of detect(text)) {
        // split/join troca TODAS as ocorrencias sem interpretar regex.
        out = out.split(value).join(placeholderFor(type, value));
      }
      return out;
    },
    // Reidrata a resposta do modelo trocando marcador de volta por valor.
    rehydrate(text) {
      let out = text;
      for (const [token, value] of toValue) out = out.split(token).join(value);
      return out;
    },
    // Nada de persistir: o escopo morre no fim da requisicao.
    size() {
      return toValue.size;
    },
  };
}

04

Reidratar a resposta sem devolver o que o modelo não deveria saber

A anonimização só é utilizável se a resposta voltar legível para o cliente. O modelo recebeu um texto com etiquetas e responde com as mesmas etiquetas, e a camada de saída troca cada uma pelo valor original antes de entregar. Isso funciona porque o mapa de volta nunca saiu do seu processo. O ponto delicado é o escopo desse mapa: ele pertence à requisição e à identidade de quem a fez, e reidratar um marcador com o valor de outra sessão seria um vazamento cruzado muito pior do que o original. Na prática isso significa nunca guardar o mapa num cache compartilhado por chave previsível, nunca reaproveitá-lo entre usuários e destruí-lo ao fim do ciclo, mesmo em caso de erro.

A reidratação também precisa de um freio contra o marcador inventado. O modelo pode devolver uma etiqueta que você nunca emitiu, seja por alucinação, seja porque alguém plantou um texto com formato de marcador no conteúdo recuperado pelo RAG. Se a sua função de troca aceitar qualquer coisa que pareça um marcador, ela vira um oráculo: basta o atacante escrever a etiqueta certa na mensagem para receber de volta o valor real de outro campo. O tratamento correto é aceitar apenas os marcadores que aquele escopo emitiu e deixar os demais intactos, além de registrar a ocorrência, porque um marcador desconhecido na saída é sinal de alucinação ou de tentativa de injeção, e vale investigar nos dois casos.

Ciclo de anonimizacao reversivel

  entrada do cliente
    "meu cpf e 529.982.247-25, pedido 11223344"
        |
        v
  [detect] regex acha candidato -> validador confirma
        |   (11223344 nao passa em isCpf: fica intacto)
        v
  [redact] "meu cpf e [CPF_1], pedido 11223344"
        |
        |  mapa { [CPF_1] -> 529.982.247-25 }  (so na memoria da requisicao)
        v
  ============ FRONTEIRA: aqui sai da sua rede ============
        |
        v
  provedor de LLM  (log, cache de prompt, amostra)
        |   nunca viu o CPF real
        v
  resposta "confirmei o [CPF_1] no pedido 11223344"
        |
        v
  [rehydrate] so troca marcadores que ESTE escopo emitiu
        |   marcador desconhecido -> mantem e registra alerta
        v
  "confirmei o 529.982.247-25 no pedido 11223344"  -> cliente

05

Nem todo dado deve voltar: minimizar antes de anonimizar

A camada de anonimização é a última linha, não a primeira. Antes de perguntar como esconder um dado, vale perguntar por que ele está no prompt. Muita PII chega ao modelo porque alguém montou o contexto com um dump do cadastro em vez de com os campos que a tarefa exige. Se o bot precisa dizer se o pedido foi entregue, ele precisa do status, não do endereço completo. Se precisa confirmar identidade, precisa saber que a confirmação bateu, não do documento inteiro. Trocar o dossiê por um resumo derivado corta a exposição na origem e reduz tokens de quebra, e o que não entra no prompt não depende de o detector funcionar.

  1. Reveja o que o contexto carrega: liste os campos que entram no prompt e marque quais a tarefa realmente usa para produzir a resposta.
  2. Substitua dado por veredito: em vez de mandar o documento para o modelo comparar, faça a comparação no seu código e mande apenas se conferiu ou não.
  3. Prefira referência a valor: mande o identificador interno do pedido e deixe o modelo pedir detalhes via tool, com a tool devolvendo já o campo mínimo.
  4. Trate o retorno de tool como fonte de PII: anonimize o resultado antes de concatená-lo ao contexto, com o mesmo escopo da requisição.
  5. Limpe o corpus do RAG na ingestão: o trecho recuperado entra no prompt como qualquer outro texto, e PII indexada vaza em toda consulta que a recuperar.
  6. Só então anonimize o que sobrou: a camada de detecção fica responsável pelo residual, não pelo volume principal.

Existe um caso em que anonimizar não basta e a resposta certa é não mandar: dado que o modelo não precisa e cuja simples presença já viola um contrato ou uma norma setorial, como dado de saúde num sistema que não tem base legal para tratá-lo com terceiros, ou credencial de qualquer tipo. Para esses, a política não é substituir por marcador, é bloquear a chamada e devolver um caminho alternativo, exatamente como um guardrail de saída bloqueia uma ação perigosa. Misturar as duas políticas na mesma função é um erro comum: a de anonimizar quer deixar a chamada seguir, a de bloquear quer interrompê-la, e a segunda precisa ser explícita para não ser silenciosamente rebaixada à primeira.

06

Provar que não vazou: o teste que falha com dado real no payload

Uma camada de anonimização sem teste é uma suposição. Ela quebra de formas discretas: alguém adiciona um campo novo ao contexto e esquece de passá-lo pelo redator, uma refatoração troca a ordem e o dado é concatenado depois da anonimização, um provedor novo é integrado por um caminho que não passa pela camada. Nenhuma dessas falhas gera erro visível, porque a chamada continua funcionando e a resposta continua boa. O único jeito de pegar é inverter a asserção: em vez de testar que o redator funciona isoladamente, teste que o payload final, o objeto exato que vai para a rede, não contém nenhum dos valores sensíveis do caso. Esse teste vive no ponto mais externo possível, idealmente num duplo do cliente HTTP que captura o corpo enviado.

// pii-leak.test.js
// A assercao e sobre o PAYLOAD QUE SAI, nao sobre a funcao de redigir.
// Assim o teste pega o campo novo que alguem esqueceu de anonimizar.

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildPayload } from '../src/build-payload.js';

const SENSITIVE = {
  cpf: '529.982.247-25',
  email: '[email protected]',
  card: '4111 1111 1111 1111',
};

test('nenhum dado sensivel atravessa a fronteira de rede', () => {
  const payload = buildPayload({
    userMessage: 'meu cpf e ' + SENSITIVE.cpf + ' e o cartao ' + SENSITIVE.card,
    history: [{ role: 'user', content: 'me manda no ' + SENSITIVE.email }],
    toolResults: [{ name: 'getOrder', content: JSON.stringify({ email: SENSITIVE.email }) }],
  });

  // Serializa TUDO que sai: nenhum campo escapa da verificacao.
  const wire = JSON.stringify(payload);

  for (const [label, value] of Object.entries(SENSITIVE)) {
    assert.ok(!wire.includes(value), 'vazou ' + label + ' no payload');
    // Tambem sem formatacao: o dado pode sair so com digitos.
    const digits = value.replace(/\D/g, '');
    if (digits.length >= 8) {
      assert.ok(!wire.includes(digits), 'vazou ' + label + ' sem formatacao');
    }
  }

  // O marcador precisa estar la: anonimizar nao pode virar apagar.
  assert.match(wire, /\[CPF_1\]/);
});

test('numero que nao e CPF nao vira marcador', () => {
  const payload = buildPayload({ userMessage: 'pedido 11223344', history: [], toolResults: [] });
  assert.match(JSON.stringify(payload), /11223344/);
});

Vale medir a camada em produção também, com dois sinais baratos. O primeiro é a contagem de marcadores emitidos por requisição, quebrada por tipo: uma queda súbita para zero num tipo que sempre aparecia significa que o detector parou de funcionar, provavelmente por uma mudança de formato na origem. O segundo é a taxa de marcador desconhecido na reidratação, que deveria ser praticamente nula: qualquer subida indica alucinação do modelo ou tentativa de injeção pelo conteúdo recuperado. Junto com o teste de payload no CI, esses dois números transformam a anonimização de promessa em propriedade verificável, que é a única forma de sustentá-la ao longo do tempo enquanto o sistema muda.

FAQ

Perguntas frequentes

Por que não basta usar expressão regular para detectar dado pessoal?

Porque a expressão regular erra nos dois sentidos e cada erro tem um custo diferente. No falso positivo, uma sequência de onze dígitos que era número de protocolo vira marcador de CPF, e o modelo perde exatamente a informação que precisava para responder, degradando a qualidade sem que ninguém associe a causa. No falso negativo, o cliente escreve o documento numa formatação que a expressão não previu e o dado atravessa a fronteira intacto. A correção é separar as duas etapas: a expressão encontra o candidato pela forma e um validador específico do tipo confirma o conteúdo, dígito verificador para CPF e CNPJ, algoritmo de Luhn para cartão. Para dado sem estrutura verificável, como nome e endereço em texto corrido, a saída é anonimizar por origem, quando o campo vem de uma tool call conhecida, e minimizar o que entra no prompt, porque o que não entra não precisa ser detectado.

O marcador deve ser sempre o mesmo para o mesmo dado?

Dentro da requisição, sim, e essa estabilidade é obrigatória: se a mesma pessoa aparece três vezes na conversa e cada ocorrência vira um marcador diferente, o modelo enxerga três pessoas distintas e a coerência da resposta desmorona. Entre requisições, a resposta padrão é não. Um marcador derivado de hash determinístico do valor, igual entre sessões e clientes, vira um pseudônimo permanente: quem tiver acesso aos logs consegue correlacionar todas as conversas do mesmo documento ao longo do tempo sem nunca ver o número, o que ainda é dado pessoal para efeito regulatório. Gerar o marcador a partir de um contador por requisição, com um mapa que morre junto com ela, dá a coerência que o modelo precisa sem criar correlação nenhuma. Se a análise realmente exigir ligar conversas do mesmo cliente, use hash com sal por tenant e trate o resultado como dado sensível.

Como garantir que nenhum dado real está vazando na prática?

Invertendo a asserção do teste. Testar que a função de anonimizar funciona isoladamente não pega a falha mais comum, que é um campo novo adicionado ao contexto por um caminho que não passa pela camada, ou uma refatoração que concatena o dado depois da anonimização. O teste que pega isso serializa o payload final, o objeto exato que vai para a rede, e afirma que ele não contém nenhum dos valores sensíveis do caso, nem na forma formatada nem só com os dígitos. Ele deve rodar no ponto mais externo possível, num duplo do cliente HTTP que captura o corpo enviado, e falhar o build quando algo escapa. Em produção, complemente com dois sinais: a contagem de marcadores emitidos por tipo, cuja queda súbita a zero denuncia um detector quebrado, e a taxa de marcador desconhecido na reidratação, cuja subida indica alucinação ou tentativa de injeção pelo conteúdo recuperado.

O dado que nunca entrou no prompt não vaza em lugar nenhum

O prompt que sai do seu servidor carrega muito mais do que a mensagem atual: histórico inteiro, retorno de tool, trecho de RAG e system prompt, e cada um desses caminhos leva dado pessoal para log, cache e amostra de monitoramento que nunca foram projetados como cofre. Minimizar primeiro e anonimizar o residual depois, com detecção validada em vez de só regex, marcador tipado e estável dentro da requisição, reidratação restrita ao escopo que a emitiu e um teste que falha quando o valor real aparece no payload, transforma privacidade de promessa em propriedade verificável. Posso desenhar e implementar essa camada no seu sistema com LLM, da minimização do contexto ao teste de vazamento no CI, para que o dado do seu cliente pare na sua rede em vez de atravessar a fronteira sem necessidade.