mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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>
281 lines
12 KiB
TypeScript
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;
|
|
},
|
|
};
|