Files
DeskcommCRM/lib/sentry/scrub.ts
T
melgarafaelandClaude Opus 5.5 ca1da775ea build(deps): Sentry 11 com coleta restrita e scrub provado pelo SDK
O Sentry 11 trocou `sendDefaultPii` por `dataCollection`, com default AMPLO
(IP, cookies, corpo de requisição/resposta, entrada/saída de IA, dados de
query, filas). Omitir a opção, que era o conserto óbvio para o TS2353, faria o
DSN da comunidade receber dado de titular de terceiros (issue #100).

- lib/sentry/privacidade.ts: ponto único com `dataCollection` restritivo
  explícito (base "v10" do MIGRATION.md + stackFrameVariables: false) e os
  hooks de scrub; espalhado nos 4 Sentry.init (server, edge, client, worker).
- lib/sentry/scrub.ts: beforeSendSpan no formato streamed (name/attributes),
  limpando também `sentry.segment.name`, headers-credencial/IP, corpo, user.* e
  client.address; beforeSend apaga request.data, request.cookies e user e passa
  a mensagem por scrubUrl (o token do path saía na mensagem do erro);
  beforeSendTransaction removido (no-op no 11, confirmado pelo guia).
- lib/sentry/privacidade.sdk.test.ts: prova pelo envelope do SDK real, com
  transporte de captura e controles positivos; 4 sabotagens ficam vermelhas.
- withSentryConfig de @sentry/nextjs/config; enableLogs removido; AGENTS.md
  cita Sentry 11; fragmento em .changes/.

Substitui #1743.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:57:31 -03:00

281 lines
12 KiB
TypeScript

/**
* Scrub compartilhado da telemetria (issue #100).
*
* Este arquivo existe porque a lógica anterior vivia TRIPLICADA, verbatim, em
* `sentry.server.config.ts`, `sentry.edge.config.ts` e `instrumentation-client.ts`.
* Foi exatamente essa triplicação que produziu o buraco: o `beforeSend` cobria
* erro e mensagem nos três, e ninguém percebeu que transação, span e breadcrumb
* têm hooks PRÓPRIOS — que não existiam em lugar nenhum. Com `tracesSampleRate: 1`,
* o canal sem sanitização era justamente o de 100% de amostragem.
*
* Agora há um ponto só. Quem adicionar um hook novo adiciona para os três runtimes.
*/
/**
* Tipos estruturais mínimos, em vez de importar de `@sentry/core`.
*
* `StreamedSpanJSON` e `Event` não são reexportados por `@sentry/nextjs`, e o
* `@sentry/core` é dependência TRANSITIVA — sob o node_modules estrito do pnpm ele
* não resolve a partir da raiz. Importar dele funcionaria na máquina de quem tem
* hoisting e quebraria no CI. Declarar só os campos que este arquivo toca mantém os
* hooks atribuíveis ao `Sentry.init` por compatibilidade estrutural, sem acoplar a
* um pacote que não é dependência direta.
*/
type EventLike = {
// `unknown` de propósito nos campos que o Sentry tipa mais largo que string
// (`query_string` é `string | Record<string,string> | Array<[string,string]>`).
// A checagem de `typeof === "string"` acontece em runtime, logo abaixo.
request?: {
url?: unknown;
query_string?: unknown;
headers?: unknown;
data?: unknown;
cookies?: unknown;
};
user?: unknown;
transaction?: string;
contexts?: { trace?: { data?: Record<string, unknown> } };
message?: string;
exception?: { values?: Array<{ value?: string }> };
};
/** Formato do span no Sentry 11 (streaming): `description` virou `name`, `data` virou `attributes`. */
type SpanLike = { name?: string; attributes?: Record<string, unknown> };
type BreadcrumbLike = { message?: string; data?: Record<string, unknown> };
/**
* Header sensível por PADRÃO, não por lista fechada.
*
* A lista anterior enumerava os headers de cada integração pelo nome. Isso tem dois
* defeitos: header de integração nova entra vazando até alguém lembrar de somar à
* lista, e o arquivo passa a nomear provider — o que a doutrina de restrição de canal
* proíbe fora de `lib/channels/` (`docs/doctrine/restricao-de-canal.md`). Casar pelo
* que torna o header sensível cobre os dois casos de uma vez.
*
* Sensível é credencial OU endereço do titular: `x-forwarded-for`, `x-real-ip`,
* `cf-connecting-ip` e afins carregam o IP de quem acessou (a mesma lista que o
* guia do Sentry 11 usa como "default do v10": forwarded, -ip, remote-, via).
*/
const SENSITIVE_HEADER =
/authorization|cookie|api[-_]?key|token|secret|password|credential|forwarded|-ip\b|remote-|^via$/i;
export function isSensitiveHeader(name: string): boolean {
return SENSITIVE_HEADER.test(name);
}
/**
* UUID tem forma exata (8-4-4-4-12 em hexadecimal) e é identificador de
* depuração, não dado do titular. Ele é separado do texto ANTES dos padrões de
* CPF e telefone, que por isso não precisam de borda de letra: com borda, o CPF
* e o telefone grudados no rótulo (`cpf12345678909`, `tel-11987654321`) saíam
* inteiros rumo ao Jev, e sem ela os padrões comiam pedaço de UUID (141 de
* 5.000 alterados, `…-a9ff-811889831080` virava `…-a9ff-[CPF]0`). O grupo de
* captura faz o `split` devolver o UUID nas posições ímpares.
*/
const UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
export function scrubMessage(input: string): string {
return input
// Chave do Jev (`apikey_<hex>_<hex>`) solta no texto. PRIMEIRO, porque os
// padrões de CPF e telefone abaixo comeriam pedaços numéricos dela e
// deixariam o resto passar.
.replace(/apikey_[A-Za-z0-9_]{16,}/g, "[CHAVE]")
// E-mail antes dos números, para o telefone não comer dígito de dentro do
// endereço e deixar o resto dele passar.
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[EMAIL]")
.split(UUID)
.map((trecho, i) => (i % 2 === 1 ? trecho : apagarCpfETelefone(trecho)))
.join("");
}
function apagarCpfETelefone(trecho: string): string {
return trecho
// Telefone como se escreve no Brasil: +55 opcional, DDD opcional (com ou
// sem parênteses), 8 ou 9 dígitos (o 9 da frente pode vir solto), e hífen,
// ponto, espaço ou nada entre os blocos — `11-98765-4321` e `11.98765.4321`
// saíam inteiros enquanto a tela prometia apagar o telefone. Com 8 dígitos
// o padrão é curto, e a borda (`[^\w-]` antes, `(?![\w-])` depois) o tira de
// dentro de hash: sem ela, 1.390 de 5.000 SHA-1 saíam alterados. Os dois
// padrões de baixo seguem pegando o número colado em outro texto.
.replace(
/(^|[^\w-])(?:\+?55\s?)?(?:\(?\d{2}\)?[-.\s]?)?(?:9[-.\s]?\d{4}|\d{4,5})[-.\s]?\d{4}(?![\w-])/g,
"$1[PHONE]",
)
// CPF com qualquer separador entre os blocos (ponto, espaço, hífen ou nada):
// `123 456 789 09` e `123.456.789.09` também são CPF de quem digita rápido.
.replace(/\d{3}[.\s-]?\d{3}[.\s-]?\d{3}[.\s-]?\d{2}/g, "[CPF]")
.replace(/\+?\d{2}\s?\d{4,5}-?\d{4}/g, "[PHONE]");
}
/**
* Rotas cujo último segmento é CREDENCIAL, não identificador.
*
* Os ~134 segmentos `[id]` do app são UUID e ficam de fora de propósito: redigir
* tudo cegamente tornaria o Sentry inútil para depurar, que é o oposto do objetivo.
* Estes são diferentes — o token É o mecanismo de autenticação:
*
* /api/v1/webhooks/<canal>/<token> — a variante por tenant é pública por desenho
* (o Caddyfile diz isso com todas as letras) e se apoia em o token ser
* imprevisível. Pior: a exigência de assinatura nasce desligada, porque nem todo
* transporte assina — então na instalação padrão o token do path é a credencial
* INTEIRA daquela rota. Publicá-lo na telemetria a anula.
* /team/accept-invite/<token> — link de convite, aberto no browser.
*
* O segmento do canal é `[^/]+` de propósito, não uma lista: canal novo ganha a
* proteção sozinho, e este arquivo não precisa nomear provider (invariante 1 de
* `docs/doctrine/restricao-de-canal.md`).
*/
const CREDENTIAL_PATH =
/(\/api\/v1\/webhooks\/[^/?#\s]+\/|\/team\/accept-invite\/)[^/?#\s]+/g;
/**
* Redige credencial de path e valor de query string, preservando as CHAVES da query.
*
* Manter as chaves é deliberado: `?cursor=[REDACTED]&limit=[REDACTED]` ainda diz o
* que a requisição estava fazendo, que é o que serve para depurar. O valor é o que
* pode carregar assinatura, token ou dado do titular.
*/
export function scrubUrl(input: string): string {
const withoutToken = input.replace(CREDENTIAL_PATH, "$1[TOKEN]");
// O `^` da alternância não é adorno: o Sentry preenche `request.query_string`
// com a query CRUA, sem o `?` na frente (`sig=abc`, não `?sig=abc`). Sem ele a
// assinatura sobrevivia nesse campo — medido, não suposto.
const withoutQueryValues = withoutToken.replace(
/(^|[?&])([^=&#\s]+)=[^&#\s]*/g,
"$1$2=[REDACTED]",
);
return scrubMessage(withoutQueryValues);
}
/**
* Atributos de span/trace que carregam URL crua na convenção OpenTelemetry.
* `sentry.segment.name` é a cópia do nome do span raiz que o Sentry 11 põe em
* TODO span — limpar só o `name` deixava o token sair por ela (medido pelo SDK).
*/
const URL_ATTRIBUTES = [
"sentry.segment.name",
"url.full",
"url.path",
"url.query",
"http.url",
"http.target",
"http.request.url",
];
function scrubAttributes(data: Record<string, unknown> | undefined): void {
if (!data) return;
for (const key of URL_ATTRIBUTES) {
const value = data[key];
if (typeof value === "string") data[key] = scrubUrl(value);
}
}
/**
* No span, header vira atributo `http.request.header.<nome>` (valor em array no
* Sentry 11). O mesmo padrão de `isSensitiveHeader` decide quais saem.
*/
const HEADER_ATTRIBUTE = /^http\.(request|response)\.header\.(.+)$/;
/**
* Atributo de span que é dado do titular, e não metadado: o corpo (o
* `requestData` anexa `http.request.body.data` com o que houver no escopo, sem
* olhar `httpBodies` — medido pelo SDK), o usuário e o IP de quem acessou.
*/
const TITULAR_ATTRIBUTE = /^(http\.(request|response)\.body\.data|user\..+|client\.address)$/;
function scrubSpanAttributes(attributes: Record<string, unknown> | undefined): void {
if (!attributes) return;
scrubAttributes(attributes);
for (const key of Object.keys(attributes)) {
const header = HEADER_ATTRIBUTE.exec(key)?.[2];
if ((header && isSensitiveHeader(header)) || TITULAR_ATTRIBUTE.test(key)) {
delete attributes[key];
}
}
}
function scrubHeaders(headers: unknown): void {
if (!headers || typeof headers !== "object") return;
const record = headers as Record<string, string>;
for (const key of Object.keys(record)) {
if (isSensitiveHeader(key)) delete record[key];
}
}
/**
* Limpa os campos do evento de erro que carregam URL ou dado do titular. O
* evento de erro ainda traz `transaction` e `contexts.trace` no Sentry 11, e o
* nome da transação entra aqui porque o `@sentry/node` puro não parametriza a
* rota; só o wrapper do Next parametriza, e nem todo caminho passa por ele.
*/
function scrubEventUrls<T extends EventLike>(event: T): T {
if (event.request) {
scrubHeaders(event.request.headers);
if (typeof event.request.url === "string") {
event.request.url = scrubUrl(event.request.url);
}
if (typeof event.request.query_string === "string") {
event.request.query_string = scrubUrl(event.request.query_string);
}
// Corpo e cookies não saem, nem se o SDK os anexar: o `requestData` do
// Sentry 11 anexa o corpo que estiver no escopo sem olhar `httpBodies`
// (que só barra a escrita) — medido em `privacidade.sdk.test.ts`.
delete event.request.data;
delete event.request.cookies;
}
// Não chamamos `setUser`; o que chega aqui é o que o SDK INFERIU (IP). Se um
// dia for preciso identificar usuário no Sentry, é decisão de LGPD, não default.
delete event.user;
if (typeof event.transaction === "string") {
event.transaction = scrubUrl(event.transaction);
}
scrubAttributes(event.contexts?.trace?.data);
return event;
}
/**
* Os hooks, prontos para espalhar dentro do `Sentry.init` de cada runtime (via
* `opcoesDePrivacidade`, em `./privacidade`). Espalhar o objeto inteiro é o
* ponto: adicionar um hook aqui cobre todos os runtimes de uma vez.
*
* Não há `beforeSendTransaction`: no Sentry 11 o span é transmitido em
* streaming, não existe mais evento de transação, e o hook é no-op (MIGRATION.md
* 11.0.0, "Replacing `beforeSendTransaction`"). O que ele limpava — o nome da
* transação e a URL nos atributos — é o `name` e os `attributes` do span raiz,
* e o `beforeSendSpan` limpa todo span, raiz (`is_segment`) inclusive.
*/
export const sentryScrubHooks = {
beforeSend<T extends EventLike>(event: T): T {
scrubEventUrls(event);
// `scrubUrl`, não só `scrubMessage`: mensagem de erro carrega URL (erro de
// fetch, de rota), e o token do path saía nela — medido pelo SDK.
if (typeof event.message === "string") {
event.message = scrubUrl(event.message);
}
if (event.exception?.values) {
for (const ex of event.exception.values) {
if (ex.value) ex.value = scrubUrl(ex.value);
}
}
return event;
},
beforeSendSpan<T extends SpanLike>(span: T): T {
if (typeof span.name === "string") {
span.name = scrubUrl(span.name);
}
scrubSpanAttributes(span.attributes);
return span;
},
beforeBreadcrumb<T extends BreadcrumbLike>(breadcrumb: T): T {
if (typeof breadcrumb.message === "string") {
breadcrumb.message = scrubUrl(breadcrumb.message);
}
const url = breadcrumb.data?.url;
if (typeof url === "string" && breadcrumb.data) {
breadcrumb.data.url = scrubUrl(url);
}
return breadcrumb;
},
};