fg-tracker-sdk 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +31 -5
- package/dist/context.d.ts +39 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +59 -5
- package/dist/context.js.map +1 -1
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +3 -1
- package/dist/database.js.map +1 -1
- package/dist/express.d.ts +6 -1
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +20 -6
- package/dist/express.js.map +1 -1
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +156 -9
- package/dist/fetch.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/infra/aws.d.ts +7 -4
- package/dist/infra/aws.d.ts.map +1 -1
- package/dist/infra/aws.js +38 -7
- package/dist/infra/aws.js.map +1 -1
- package/dist/infra/lambda.d.ts +2 -0
- package/dist/infra/lambda.d.ts.map +1 -1
- package/dist/infra/lambda.js +6 -3
- package/dist/infra/lambda.js.map +1 -1
- package/dist/infra/messaging.d.ts +10 -2
- package/dist/infra/messaging.d.ts.map +1 -1
- package/dist/infra/messaging.js +22 -3
- package/dist/infra/messaging.js.map +1 -1
- package/dist/logs/shipper.d.ts.map +1 -1
- package/dist/logs/shipper.js +15 -4
- package/dist/logs/shipper.js.map +1 -1
- package/dist/logs/types.d.ts +8 -0
- package/dist/logs/types.d.ts.map +1 -1
- package/dist/monitors.d.ts.map +1 -1
- package/dist/monitors.js +3 -1
- package/dist/monitors.js.map +1 -1
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +8 -3
- package/dist/next.js.map +1 -1
- package/dist/node-http.d.ts.map +1 -1
- package/dist/node-http.js +12 -3
- package/dist/node-http.js.map +1 -1
- package/dist/normalize.d.ts +58 -8
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +383 -79
- package/dist/normalize.js.map +1 -1
- package/dist/pg.d.ts +12 -2
- package/dist/pg.d.ts.map +1 -1
- package/dist/pg.js +464 -213
- package/dist/pg.js.map +1 -1
- package/dist/scrub.d.ts +62 -4
- package/dist/scrub.d.ts.map +1 -1
- package/dist/scrub.js +656 -11
- package/dist/scrub.js.map +1 -1
- package/dist/tracker.d.ts +45 -5
- package/dist/tracker.d.ts.map +1 -1
- package/dist/tracker.js +93 -17
- package/dist/tracker.js.map +1 -1
- package/dist/types.d.ts +44 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
package/dist/scrub.js
CHANGED
|
@@ -1,30 +1,675 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remoção de dados sensíveis antes
|
|
3
|
-
* conhecidas viram `[Filtered]`, em qualquer profundidade
|
|
2
|
+
* Remoção de dados sensíveis antes de enfileirar (CONTRATO.md §22):
|
|
3
|
+
* - **por chave**: valores de chaves conhecidas viram `[Filtered]`, em qualquer profundidade;
|
|
4
|
+
* - **por valor**: JWT, `Bearer`/`Basic`, cartão (Luhn), CPF formatado, chaves privadas e tokens
|
|
5
|
+
* de formato conhecido viram `[Filtered]`; `chave=valor` com chave sensível dentro de texto também;
|
|
6
|
+
* e-mails viram `<email>` (o mesmo marcador da normalização da API, §16: a fingerprint não muda);
|
|
7
|
+
* - **URLs**: sem usuário/senha; valores da query (e de parâmetros no fragmento) viram `[Filtered]`,
|
|
8
|
+
* as chaves ficam. Com `sendDefaultPii: true` só os valores de chaves sensíveis saem.
|
|
9
|
+
*
|
|
10
|
+
* Identificadores (`traceId`, `spanId`, `requestId`, `user.id`…), `user.email`/`user.name` (dados
|
|
11
|
+
* que a aplicação entregou de propósito) e o SQL já normalizado não passam pela limpeza por valor.
|
|
12
|
+
* Os padrões são lineares: nada aqui pode travar o event loop com uma string grande.
|
|
4
13
|
*/
|
|
5
14
|
export const DEFAULT_SCRUB_FIELDS = [
|
|
6
15
|
'password', 'passwd', 'secret', 'token', 'authorization', 'cookie', 'set-cookie',
|
|
7
16
|
'api_key', 'apikey', 'api-key', 'access_token', 'refresh_token', 'credit_card', 'card_number', 'cvv', 'ssn', 'private_key',
|
|
17
|
+
'cpf', 'jwt', 'secret_access_key', 'secret_key',
|
|
8
18
|
];
|
|
9
19
|
export const FILTERED = '[Filtered]';
|
|
20
|
+
export const EMAIL_PLACEHOLDER = '<email>';
|
|
10
21
|
const MAX_DEPTH = 6;
|
|
22
|
+
/** Objeto ou array além de `MAX_DEPTH`: não sai sem passar pela limpeza. */
|
|
23
|
+
export const MAX_DEPTH_MARKER = '[MaxDepth]';
|
|
24
|
+
/** Parâmetros de URL que levam credencial mesmo sem casar com `fields` (assinaturas, `?key=`, OAuth). */
|
|
25
|
+
const SENSITIVE_QUERY_KEYS = new Set([
|
|
26
|
+
'sig', 'signature', 'x-amz-signature', 'x-amz-credential', 'x-amz-security-token', 'awsaccesskeyid',
|
|
27
|
+
'x-goog-signature', 'x-goog-credential', 'key', 'code', 'id_token', 'session', 'sessionid', 'auth', 'otp',
|
|
28
|
+
]);
|
|
29
|
+
/** Chaves cujo valor é uma URL: limpas por `scrubUrl`. */
|
|
30
|
+
const URL_KEYS = new Set(['url', 'http.url', 'http.target', 'url.full']);
|
|
31
|
+
/** Chaves com valor que a aplicação entregou de propósito ou que já saem normalizados. */
|
|
32
|
+
const VALUE_EXEMPT_KEYS = new Set([
|
|
33
|
+
'user.email', 'user.name', 'traceparent', 'tracestate', 'db.query.text', 'db.query.fingerprint',
|
|
34
|
+
// Dimensões do deploy que a API usa para agrupar (releases, ambientes, serviços): um release com
|
|
35
|
+
// carimbo de build (`20261017090915`) passa no Luhn ~1 vez em 10 e não pode virar `[Filtered]`.
|
|
36
|
+
'release', 'serviceName', 'environment', 'deployment.environment.name', 'service.name', 'service.version',
|
|
37
|
+
]);
|
|
38
|
+
/** Identificadores (`id`, `traceId`, `user.id`, `event_id`, `messaging.message.id`): nunca alterados por valor. */
|
|
39
|
+
function isIdentifierKey(key) {
|
|
40
|
+
return key === 'id' || key === 'ID' || /(?:[._-][iI][dD]|[a-z0-9]Id|ID)$/.test(key);
|
|
41
|
+
}
|
|
42
|
+
/** Formas comparáveis de cada lista de campos, calculadas uma vez por lista (a limpeza roda por chave). */
|
|
43
|
+
const normalizedFields = new WeakMap();
|
|
44
|
+
function normalized(fields) {
|
|
45
|
+
let list = normalizedFields.get(fields);
|
|
46
|
+
if (!list || list.length !== fields.length) {
|
|
47
|
+
list = fields.map((raw) => {
|
|
48
|
+
const field = raw.toLowerCase();
|
|
49
|
+
return { field, compact: field.replace(/[_-]/g, '') };
|
|
50
|
+
});
|
|
51
|
+
normalizedFields.set(fields, list);
|
|
52
|
+
}
|
|
53
|
+
return list;
|
|
54
|
+
}
|
|
55
|
+
/** A chave casa com um campo (sem diferenciar maiúsculas: `scrubFields: ['internalRef']` pega `internalref`). */
|
|
11
56
|
export function shouldScrub(key, fields) {
|
|
12
57
|
const lower = key.toLowerCase();
|
|
13
|
-
return fields.some((field) => lower === field || lower.endsWith(`_${field}`) || lower.endsWith(`-${field}`) || lower.endsWith(`.${field}`) || lower.endsWith(
|
|
58
|
+
return normalized(fields).some(({ field, compact }) => lower === field || lower.endsWith(`_${field}`) || lower.endsWith(`-${field}`) || lower.endsWith(`.${field}`) || lower.endsWith(compact));
|
|
59
|
+
}
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
61
|
+
/* Padrões por valor */
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|$)/g;
|
|
64
|
+
/** URL com esquema dentro de um texto (`https://…`, `postgres://user:senha@…`). */
|
|
65
|
+
const EMBEDDED_URL = /\b[a-zA-Z][a-zA-Z0-9+.-]{1,15}:\/\/[^\s'"<>`]+/g;
|
|
66
|
+
/** Tokens de formato publicado: FG Tracker (`fgsk_`, `fgt_`), AWS, GitHub, Stripe, Slack, OpenAI/OpenRouter/Anthropic, Google. */
|
|
67
|
+
const KNOWN_TOKENS = [
|
|
68
|
+
/\b(?:fgsk|fgt)_[A-Za-z0-9_-]{16,256}/g,
|
|
69
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
70
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g,
|
|
71
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,255}/g,
|
|
72
|
+
/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,255}/g,
|
|
73
|
+
/\bwhsec_[A-Za-z0-9+/=]{16,255}/g,
|
|
74
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,255}/g,
|
|
75
|
+
/\bsk-[A-Za-z0-9_-]{32,255}/g,
|
|
76
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
77
|
+
];
|
|
78
|
+
/** `Bearer <token>`/`Basic <credencial>`: só com um dígito no token, para não pegar prosa ("Basic authentication"). */
|
|
79
|
+
const AUTH_SCHEME = /\b(Bearer|Basic)(\s+)(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]+=*/gi;
|
|
80
|
+
/** Candidato a cartão: 13–19 dígitos começando pelos prefixos de bandeira (2–6), com espaço ou hífen opcional. */
|
|
81
|
+
const CARD_CANDIDATE = /\b[2-6](?:[ -]?\d){12,18}\b/g;
|
|
82
|
+
const CPF_FORMATTED = /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/g;
|
|
83
|
+
function luhnValid(digits) {
|
|
84
|
+
let sum = 0;
|
|
85
|
+
let double = false;
|
|
86
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
87
|
+
let n = digits.charCodeAt(i) - 48;
|
|
88
|
+
if (double) {
|
|
89
|
+
n *= 2;
|
|
90
|
+
if (n > 9)
|
|
91
|
+
n -= 9;
|
|
92
|
+
}
|
|
93
|
+
sum += n;
|
|
94
|
+
double = !double;
|
|
95
|
+
}
|
|
96
|
+
return sum % 10 === 0;
|
|
97
|
+
}
|
|
98
|
+
function cpfValid(formatted) {
|
|
99
|
+
const digits = formatted.replace(/\D/g, '');
|
|
100
|
+
if (digits.length !== 11 || /^(\d)\1{10}$/.test(digits))
|
|
101
|
+
return false;
|
|
102
|
+
const check = (length) => {
|
|
103
|
+
let sum = 0;
|
|
104
|
+
for (let i = 0; i < length; i += 1)
|
|
105
|
+
sum += (digits.charCodeAt(i) - 48) * (length + 1 - i);
|
|
106
|
+
const rest = (sum * 10) % 11;
|
|
107
|
+
return rest === 10 ? 0 : rest;
|
|
108
|
+
};
|
|
109
|
+
return check(9) === digits.charCodeAt(9) - 48 && check(10) === digits.charCodeAt(10) - 48;
|
|
110
|
+
}
|
|
111
|
+
const isWordChar = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95;
|
|
112
|
+
const isLetter = (c) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
113
|
+
/** Caractere de base64url (segmentos de JWT) e de nome de chave (`a.b-c_d`). */
|
|
114
|
+
const isB64UrlChar = (c) => isWordChar(c) || c === 45;
|
|
115
|
+
const isKeyChar = (c) => isWordChar(c) || c === 45 || c === 46;
|
|
116
|
+
/** Fim do valor sem aspas de `chave=valor`: espaço ou um destes delimitadores. */
|
|
117
|
+
/** Caractere de token colado a um valor (`[abc]def`, `"abc"def`): letras, dígitos e `-_.~+/=%`. */
|
|
118
|
+
const isTokenChar = (c) => isB64UrlChar(c) || c === 46 || c === 126 || c === 43 || c === 47 || c === 61 || c === 37;
|
|
119
|
+
const isValueStop = (c) => c <= 32 || c === 38 || c === 59 || c === 44 || c === 39 || c === 34 || c === 60 || c === 62;
|
|
120
|
+
/**
|
|
121
|
+
* Como `runEnd`, sem engolir a aspa escapada de um texto dentro de string: uma sequência de barras
|
|
122
|
+
* seguida de aspa com número ímpar de barras (`\"`, `\\\"`) é aspa do texto de fora e fica fora do
|
|
123
|
+
* valor; um número par (`\\` antes da aspa) são barras literais e fazem parte dele.
|
|
124
|
+
*/
|
|
125
|
+
function valueRunEnd(text, from, accept) {
|
|
126
|
+
let i = from;
|
|
127
|
+
while (i < text.length) {
|
|
128
|
+
const c = text.charCodeAt(i);
|
|
129
|
+
if (c === 92) {
|
|
130
|
+
let slashes = 1;
|
|
131
|
+
while (text.charCodeAt(i + slashes) === 92)
|
|
132
|
+
slashes += 1;
|
|
133
|
+
if (text.charCodeAt(i + slashes) === 34 && slashes % 2 === 1)
|
|
134
|
+
return i;
|
|
135
|
+
i += slashes;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!accept(c))
|
|
139
|
+
return i;
|
|
140
|
+
i += 1;
|
|
141
|
+
}
|
|
142
|
+
return i;
|
|
143
|
+
}
|
|
144
|
+
function runEnd(text, from, accept) {
|
|
145
|
+
let i = from;
|
|
146
|
+
while (i < text.length && accept(text.charCodeAt(i)))
|
|
147
|
+
i += 1;
|
|
148
|
+
return i;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* JWT/JWS compacto (`eyJ…` `.` `…` `.` `…`; o cabeçalho em base64url sempre começa com `eyJ`, `{"`)
|
|
152
|
+
* → `[Filtered]`. Varredura linear: um segmento que não termina em `.` descarta todos os candidatos
|
|
153
|
+
* dentro dele de uma vez (todos acabariam no mesmo ponto). Com a expressão
|
|
154
|
+
* `\beyJ[…]{4,}\.[…]{4,}\.[…]*`, cada `eyJ` depois de um hífen relia o resto do texto procurando o
|
|
155
|
+
* ponto: `'eyJaaaa-'.repeat(16000)` levava 2 s.
|
|
156
|
+
*/
|
|
157
|
+
export function replaceJwts(text) {
|
|
158
|
+
let at = text.indexOf('eyJ');
|
|
159
|
+
if (at < 0)
|
|
160
|
+
return text;
|
|
161
|
+
let out = '';
|
|
162
|
+
let last = 0;
|
|
163
|
+
while (at >= 0) {
|
|
164
|
+
if (at > 0 && isWordChar(text.charCodeAt(at - 1))) {
|
|
165
|
+
at = text.indexOf('eyJ', at + 1);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const header = runEnd(text, at, isB64UrlChar);
|
|
169
|
+
if (header - at < 7 || text.charCodeAt(header) !== 46) {
|
|
170
|
+
at = text.indexOf('eyJ', header);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const payload = runEnd(text, header + 1, isB64UrlChar);
|
|
174
|
+
if (payload - header - 1 < 4 || text.charCodeAt(payload) !== 46) {
|
|
175
|
+
at = text.indexOf('eyJ', payload);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const signature = runEnd(text, payload + 1, isB64UrlChar);
|
|
179
|
+
out += text.slice(last, at) + FILTERED;
|
|
180
|
+
last = signature;
|
|
181
|
+
at = text.indexOf('eyJ', signature);
|
|
182
|
+
}
|
|
183
|
+
return out + text.slice(last);
|
|
184
|
+
}
|
|
185
|
+
/** Barras invertidas logo antes da posição `i`. */
|
|
186
|
+
function backslashesBefore(text, i) {
|
|
187
|
+
let n = 0;
|
|
188
|
+
while (i - n > 0 && text.charCodeAt(i - n - 1) === 92)
|
|
189
|
+
n += 1;
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Nível de escape de uma aspa: `r` barras antes dela (0 em JSON, 1 em JSON dentro de string, 3 em
|
|
194
|
+
* dois níveis…). Uma aspa com `L` barras antes delimita strings do nível `r` quando
|
|
195
|
+
* `L % (2r + 2) === r`; com mais barras é conteúdo (aspa escapada); com menos, é o fim da string de
|
|
196
|
+
* fora, onde o valor de dentro termina.
|
|
197
|
+
*/
|
|
198
|
+
function delimitsLevel(slashes, r) {
|
|
199
|
+
return slashes % (2 * r + 2) === r;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Fim (exclusivo) do texto entre aspas no nível `r`, a partir de `from` (depois da aspa de
|
|
203
|
+
* abertura): depois da aspa que fecha, ou na aspa de um nível de fora. -1 numa quebra de linha.
|
|
204
|
+
*/
|
|
205
|
+
function levelQuotedEnd(text, from, quote, r) {
|
|
206
|
+
for (let i = from; i < text.length; i += 1) {
|
|
207
|
+
const c = text.charCodeAt(i);
|
|
208
|
+
if (c === 10)
|
|
209
|
+
return -1;
|
|
210
|
+
if (c !== quote && c !== 34)
|
|
211
|
+
continue;
|
|
212
|
+
const slashes = backslashesBefore(text, i);
|
|
213
|
+
if (c === 34 && slashes < r)
|
|
214
|
+
return i - slashes;
|
|
215
|
+
if (c === quote && delimitsLevel(slashes, r))
|
|
216
|
+
return i + 1;
|
|
217
|
+
}
|
|
218
|
+
return -1;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Fim do objeto ou array aberto em `start` (`{`/`[`) no nível de escape `r`, respeitando as strings
|
|
222
|
+
* desse nível; `text.length` quando não fecha (o resto do texto conta como o valor, escondido
|
|
223
|
+
* inteiro). Para na aspa de um nível de fora (fim da string que contém o JSON).
|
|
224
|
+
*/
|
|
225
|
+
function balancedEnd(text, start, r = 0) {
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let quote = 0;
|
|
228
|
+
for (let i = start; i < text.length; i += 1) {
|
|
229
|
+
const c = text.charCodeAt(i);
|
|
230
|
+
if (c === 34 || (c === 39 && r === 0)) {
|
|
231
|
+
const slashes = backslashesBefore(text, i);
|
|
232
|
+
if (c === 34 && slashes < r)
|
|
233
|
+
return i - slashes;
|
|
234
|
+
if (!delimitsLevel(slashes, r))
|
|
235
|
+
continue;
|
|
236
|
+
if (!quote)
|
|
237
|
+
quote = c;
|
|
238
|
+
else if (quote === c)
|
|
239
|
+
quote = 0;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (quote)
|
|
243
|
+
continue;
|
|
244
|
+
if (c === 123 || c === 91)
|
|
245
|
+
depth += 1;
|
|
246
|
+
else if ((c === 125 || c === 93) && --depth === 0)
|
|
247
|
+
return i + 1;
|
|
248
|
+
}
|
|
249
|
+
return text.length;
|
|
250
|
+
}
|
|
251
|
+
const isSpace = (c) => c === 32 || c === 9 || c === 10 || c === 13;
|
|
252
|
+
/** Fim de um valor JSON sem aspas (número, `true`, texto solto): espaço, `,`, `}` ou `]`. */
|
|
253
|
+
const isJsonStop = (c) => c <= 32 || c === 44 || c === 125 || c === 93;
|
|
254
|
+
/**
|
|
255
|
+
* `"chave": valor` (JSON ou objeto literal dentro de texto) com chave sensível: o valor inteiro vira
|
|
256
|
+
* `[Filtered]`, seja texto entre aspas, número, objeto ou array. Linear: a partir de cada `:`, a
|
|
257
|
+
* chave é lida para trás (entre aspas simples, duplas ou escapadas `\"`, de JSON dentro de string)
|
|
258
|
+
* e só o valor de chave sensível é lido para a frente. O valor de uma chave comum não é pulado: um
|
|
259
|
+
* par sensível dentro dele (`{"outer":{"password":…}}`) é achado no `:` seguinte.
|
|
260
|
+
*/
|
|
261
|
+
function replaceQuotedKeyValues(text, fields) {
|
|
262
|
+
let colon = text.indexOf(':');
|
|
263
|
+
if (colon < 0)
|
|
264
|
+
return text;
|
|
265
|
+
let out = '';
|
|
266
|
+
let last = 0;
|
|
267
|
+
while (colon >= 0) {
|
|
268
|
+
let close = colon - 1;
|
|
269
|
+
while (close >= last && isSpace(text.charCodeAt(close)))
|
|
270
|
+
close -= 1;
|
|
271
|
+
const quote = close >= last ? text.charCodeAt(close) : 0;
|
|
272
|
+
// Nível de escape da chave: 0 (`"k"`), 1 (`\"k\"`), 3 (`\\\"k\\\"`)…; aspas simples só no nível 0.
|
|
273
|
+
const r = quote === 34 || quote === 39 ? backslashesBefore(text, close) : -1;
|
|
274
|
+
if (r < 0 || (r & (r + 1)) !== 0 || (quote === 39 && r !== 0)) {
|
|
275
|
+
colon = text.indexOf(':', colon + 1);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const keyEnd = close - r;
|
|
279
|
+
let start = keyEnd;
|
|
280
|
+
while (start > last && keyEnd - start < 64 && isKeyChar(text.charCodeAt(start - 1)))
|
|
281
|
+
start -= 1;
|
|
282
|
+
const opened = start > 0 && text.charCodeAt(start - 1) === quote && backslashesBefore(text, start - 1) === r;
|
|
283
|
+
if (start === keyEnd || !opened || !isLetter(text.charCodeAt(start)) || !shouldScrub(text.slice(start, keyEnd), fields)) {
|
|
284
|
+
colon = text.indexOf(':', colon + 1);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
let value = colon + 1;
|
|
288
|
+
while (value < text.length && isSpace(text.charCodeAt(value)))
|
|
289
|
+
value += 1;
|
|
290
|
+
let slashes = 0;
|
|
291
|
+
while (text.charCodeAt(value + slashes) === 92)
|
|
292
|
+
slashes += 1;
|
|
293
|
+
const open = text.charCodeAt(value + slashes);
|
|
294
|
+
const level = '\\'.repeat(r);
|
|
295
|
+
let end;
|
|
296
|
+
let inner;
|
|
297
|
+
let replacement = `${level}"${FILTERED}${level}"`;
|
|
298
|
+
const quotedEnd = slashes === r && (open === 34 || (open === 39 && r === 0)) ? levelQuotedEnd(text, value + r + 1, open, r) : -1;
|
|
299
|
+
if (quotedEnd >= 0) {
|
|
300
|
+
end = quotedEnd;
|
|
301
|
+
const closed = text.charCodeAt(end - 1) === open && end - 1 > value + r;
|
|
302
|
+
inner = text.slice(value + r + 1, closed ? end - 1 - r : end);
|
|
303
|
+
replacement = `${level}${text[value + r]}${FILTERED}${level}${text[value + r]}`;
|
|
304
|
+
}
|
|
305
|
+
else if (slashes === r && (open === 34 || open === 39)) {
|
|
306
|
+
// Aspa que não fecha na linha: o resto do valor sem aspas.
|
|
307
|
+
end = valueRunEnd(text, value + r + 1, (c) => !isJsonStop(c));
|
|
308
|
+
inner = text.slice(value + r + 1, end);
|
|
309
|
+
}
|
|
310
|
+
else if (slashes === 0 && (open === 123 || open === 91)) {
|
|
311
|
+
end = balancedEnd(text, value, r);
|
|
312
|
+
inner = text.slice(value, end);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
end = valueRunEnd(text, value, (c) => !isJsonStop(c) && c !== 34);
|
|
316
|
+
inner = text.slice(value, end);
|
|
317
|
+
}
|
|
318
|
+
// `"k":[a]b` ou `"k":"a"b` (texto que não é JSON válido): o valor vai até o fim do valor sem aspas.
|
|
319
|
+
if (end < text.length && end > value && isTokenChar(text.charCodeAt(end))) {
|
|
320
|
+
end = valueRunEnd(text, end, (c) => !isJsonStop(c) && c !== 34 && c !== 41);
|
|
321
|
+
inner = text.slice(value, end);
|
|
322
|
+
replacement = `${level}"${FILTERED}${level}"`;
|
|
323
|
+
}
|
|
324
|
+
// Valor vazio, `null` ou marcador completo: nada a esconder; segue depois dele (sem reler).
|
|
325
|
+
if (!inner || inner === 'null' || isPlaceholder(inner)) {
|
|
326
|
+
colon = text.indexOf(':', Math.max(end, colon + 1));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
out += text.slice(last, value) + replacement;
|
|
330
|
+
last = end;
|
|
331
|
+
colon = text.indexOf(':', end);
|
|
332
|
+
}
|
|
333
|
+
return out + text.slice(last);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* `chave=valor` com chave sensível dentro de texto (query, form, `senha=…` numa mensagem): o valor
|
|
337
|
+
* inteiro, sem teto de tamanho, vira `[Filtered]` (entre aspas, as aspas ficam). Linear: a partir de
|
|
338
|
+
* cada `=`, a chave é lida para trás (até 64 caracteres) e só o valor de chave sensível é lido para a
|
|
339
|
+
* frente; o valor de uma chave comum não esconde o par seguinte (`next=/cb?token=abc`).
|
|
340
|
+
*/
|
|
341
|
+
function replaceKeyEqualsValue(text, fields) {
|
|
342
|
+
let eq = text.indexOf('=');
|
|
343
|
+
if (eq < 0)
|
|
344
|
+
return text;
|
|
345
|
+
let out = '';
|
|
346
|
+
let last = 0;
|
|
347
|
+
while (eq >= 0) {
|
|
348
|
+
let start = eq;
|
|
349
|
+
while (start > last && eq - start < 64 && isKeyChar(text.charCodeAt(start - 1)))
|
|
350
|
+
start -= 1;
|
|
351
|
+
while (start < eq && !isLetter(text.charCodeAt(start)))
|
|
352
|
+
start += 1;
|
|
353
|
+
if (start === eq || !shouldScrub(text.slice(start, eq), fields)) {
|
|
354
|
+
eq = text.indexOf('=', eq + 1);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Aspas do valor no nível de escape do texto: `"x"`, `\"x\"` (texto dentro de string JSON)…
|
|
358
|
+
let slashes = 0;
|
|
359
|
+
while (text.charCodeAt(eq + 1 + slashes) === 92)
|
|
360
|
+
slashes += 1;
|
|
361
|
+
const open = text.charCodeAt(eq + 1 + slashes);
|
|
362
|
+
const isQuote = (open === 34 || (open === 39 && slashes === 0)) && (slashes & (slashes + 1)) === 0;
|
|
363
|
+
const quoted = isQuote ? levelQuotedEnd(text, eq + 2 + slashes, open, slashes) : -1;
|
|
364
|
+
const nested = slashes === 0 && (open === 123 || open === 91);
|
|
365
|
+
let end = nested ? balancedEnd(text, eq + 1) : quoted >= 0 ? quoted : valueRunEnd(text, isQuote ? eq + 2 + slashes : eq + 1, (c) => !isValueStop(c));
|
|
366
|
+
// `[abc]def` ou `"abc"def`: o valor vai até o delimitador, nada do que vem colado sobra.
|
|
367
|
+
const glued = (nested || quoted >= 0) && end < text.length && isTokenChar(text.charCodeAt(end));
|
|
368
|
+
if (glued)
|
|
369
|
+
end = valueRunEnd(text, end, (c) => isTokenChar(c) || (!isValueStop(c) && c !== 41));
|
|
370
|
+
const value = text.slice(eq + 1, end);
|
|
371
|
+
const level = '\\'.repeat(slashes);
|
|
372
|
+
const closed = quoted >= 0 && !glued && text.charCodeAt(quoted - 1) === open;
|
|
373
|
+
const inner = quoted >= 0 && !glued ? text.slice(eq + 2 + slashes, closed ? quoted - 1 - slashes : quoted) : value.replace(/^\\*["']/, '');
|
|
374
|
+
// Valor vazio ou marcador completo: nada a esconder; segue depois dele (sem reler).
|
|
375
|
+
if (!inner || isPlaceholder(inner)) {
|
|
376
|
+
eq = text.indexOf('=', Math.max(end, eq + 1));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const quote = String.fromCharCode(open);
|
|
380
|
+
out += text.slice(last, eq + 1) + (quoted >= 0 && !glued ? `${level}${quote}${FILTERED}${level}${quote}` : FILTERED);
|
|
381
|
+
last = end;
|
|
382
|
+
eq = text.indexOf('=', end);
|
|
383
|
+
}
|
|
384
|
+
return out + text.slice(last);
|
|
385
|
+
}
|
|
386
|
+
const isLocalChar = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 46 || c === 95 || c === 37 || c === 43 || c === 45;
|
|
387
|
+
const isDomainChar = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 46 || c === 45;
|
|
388
|
+
/**
|
|
389
|
+
* E-mails → `<email>`. Varredura a partir de cada `@` (linear): uma expressão `[\w.]+@` testada
|
|
390
|
+
* em cada posição de uma string longa sem `@` seria quadrática.
|
|
391
|
+
*/
|
|
392
|
+
export function replaceEmails(text) {
|
|
393
|
+
let at = text.indexOf('@');
|
|
394
|
+
if (at < 0)
|
|
395
|
+
return text;
|
|
396
|
+
let out = '';
|
|
397
|
+
let last = 0;
|
|
398
|
+
while (at >= 0) {
|
|
399
|
+
let start = at;
|
|
400
|
+
while (start > last && at - start < 64 && isLocalChar(text.charCodeAt(start - 1)))
|
|
401
|
+
start -= 1;
|
|
402
|
+
while (start < at && text[start] === '.')
|
|
403
|
+
start += 1;
|
|
404
|
+
let end = at + 1;
|
|
405
|
+
while (end < text.length && end - at <= 255 && isDomainChar(text.charCodeAt(end)))
|
|
406
|
+
end += 1;
|
|
407
|
+
while (end > at + 1 && (text[end - 1] === '.' || text[end - 1] === '-'))
|
|
408
|
+
end -= 1;
|
|
409
|
+
const domain = text.slice(at + 1, end);
|
|
410
|
+
const dot = domain.lastIndexOf('.');
|
|
411
|
+
if (start < at && dot > 0 && /^[A-Za-z]{2,24}$/.test(domain.slice(dot + 1))) {
|
|
412
|
+
out += text.slice(last, start) + EMAIL_PLACEHOLDER;
|
|
413
|
+
last = end;
|
|
414
|
+
at = text.indexOf('@', end);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
at = text.indexOf('@', at + 1);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out + text.slice(last);
|
|
421
|
+
}
|
|
422
|
+
/** Valor que já é marcador ou placeholder de SQL/URL: nada a limpar. */
|
|
423
|
+
/** Marcador completo (`[Filtered]`, `REDACTED`, placeholder de SQL): só o marcador inteiro, nunca um prefixo. */
|
|
424
|
+
function isPlaceholder(value) {
|
|
425
|
+
return value === FILTERED || value === 'REDACTED' || value === '?' || value === "'?'" || (value.length < 12 && /^\$\d+$/.test(value));
|
|
426
|
+
}
|
|
427
|
+
const SCRIPT_PATH = /\.(?:[mc]?[jt]sx?|vue|svelte|astro|html?|wasm)$/i;
|
|
428
|
+
/**
|
|
429
|
+
* Início do `:linha[:coluna][)]` de um frame de stack colado ao fim de uma URL de script com query
|
|
430
|
+
* (`…/app.js?v=20261017:1:24567)`, `@…/App.tsx?t=1792234743617:12:5`); `url.length` quando não há.
|
|
431
|
+
* Sem isso o sufixo virava parte do valor da query e saía como `[Filtered]`, e a API perdia linha,
|
|
432
|
+
* coluna e (no formato do Firefox/Safari) o frame inteiro. Lido do fim, em tempo linear.
|
|
433
|
+
*/
|
|
434
|
+
function frameLocationStart(url) {
|
|
435
|
+
const cut = url.search(/[?#]/);
|
|
436
|
+
if (cut < 0 || !SCRIPT_PATH.test(url.slice(0, cut)))
|
|
437
|
+
return url.length;
|
|
438
|
+
let i = url.length;
|
|
439
|
+
if (url.charCodeAt(i - 1) === 41)
|
|
440
|
+
i -= 1;
|
|
441
|
+
let start = url.length;
|
|
442
|
+
for (let groups = 0; groups < 2; groups += 1) {
|
|
443
|
+
let j = i;
|
|
444
|
+
while (j > cut && url.charCodeAt(j - 1) >= 48 && url.charCodeAt(j - 1) <= 57)
|
|
445
|
+
j -= 1;
|
|
446
|
+
if (j === i || url.charCodeAt(j - 1) !== 58)
|
|
447
|
+
break;
|
|
448
|
+
i = j - 1;
|
|
449
|
+
start = i;
|
|
450
|
+
}
|
|
451
|
+
return start;
|
|
452
|
+
}
|
|
453
|
+
/** Limpeza por valor de um texto livre (mensagem de erro, atributo, nome de span, linha de log). */
|
|
454
|
+
export function scrubText(text, options) {
|
|
455
|
+
if (text.length < 6 || options.fields.length === 0)
|
|
456
|
+
return text;
|
|
457
|
+
let out = text;
|
|
458
|
+
if (out.includes('PRIVATE KEY-----'))
|
|
459
|
+
out = out.replace(PRIVATE_KEY_BLOCK, FILTERED);
|
|
460
|
+
if (out.includes('://')) {
|
|
461
|
+
out = out.replace(EMBEDDED_URL, (url) => {
|
|
462
|
+
const at = frameLocationStart(url);
|
|
463
|
+
return scrubUrl(url.slice(0, at), options) + url.slice(at);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return scrubPatterns(out, options);
|
|
467
|
+
}
|
|
468
|
+
/** Padrões por valor, sem tratar URLs (o `scrubUrl` chama isto no próprio resultado). */
|
|
469
|
+
function scrubPatterns(text, options) {
|
|
470
|
+
let out = text;
|
|
471
|
+
out = replaceJwts(out);
|
|
472
|
+
for (const pattern of KNOWN_TOKENS)
|
|
473
|
+
out = out.replace(pattern, FILTERED);
|
|
474
|
+
out = out.replace(AUTH_SCHEME, (_match, scheme, space) => `${scheme}${space}${FILTERED}`);
|
|
475
|
+
out = replaceKeyEqualsValue(out, options.fields);
|
|
476
|
+
out = replaceQuotedKeyValues(out, options.fields);
|
|
477
|
+
out = out.replace(CARD_CANDIDATE, (match) => {
|
|
478
|
+
const digits = match.replace(/[ -]/g, '');
|
|
479
|
+
return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? FILTERED : match;
|
|
480
|
+
});
|
|
481
|
+
if (out.includes('.'))
|
|
482
|
+
out = out.replace(CPF_FORMATTED, (match) => (cpfValid(match) ? FILTERED : match));
|
|
483
|
+
return replaceEmails(out);
|
|
484
|
+
}
|
|
485
|
+
function decode(value) {
|
|
486
|
+
try {
|
|
487
|
+
return decodeURIComponent(value.replace(/\+/g, ' '));
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** `a=1&token=x&flag` → valores trocados por `[Filtered]` (ou só os sensíveis, com `sendDefaultPii`). */
|
|
494
|
+
function scrubQuery(query, options) {
|
|
495
|
+
return query
|
|
496
|
+
.split('&')
|
|
497
|
+
.map((pair) => {
|
|
498
|
+
if (!pair)
|
|
499
|
+
return pair;
|
|
500
|
+
const eq = pair.indexOf('=');
|
|
501
|
+
if (eq < 0) {
|
|
502
|
+
// Parâmetro sem valor: fica se parece um nome curto (`?debug`); senão pode ser o próprio segredo.
|
|
503
|
+
return /^[A-Za-z][A-Za-z0-9_.-]{0,15}$/.test(pair) ? pair : FILTERED;
|
|
504
|
+
}
|
|
505
|
+
const key = pair.slice(0, eq);
|
|
506
|
+
const value = pair.slice(eq + 1);
|
|
507
|
+
if (!value || isPlaceholder(value))
|
|
508
|
+
return pair;
|
|
509
|
+
const name = decode(key).toLowerCase();
|
|
510
|
+
if (!options.sendDefaultPii || SENSITIVE_QUERY_KEYS.has(name) || shouldScrub(name, options.fields))
|
|
511
|
+
return `${key}=${FILTERED}`;
|
|
512
|
+
const decoded = decode(value);
|
|
513
|
+
const cleaned = scrubPatterns(decoded, options);
|
|
514
|
+
return cleaned === decoded ? pair : `${key}=${cleaned}`;
|
|
515
|
+
})
|
|
516
|
+
.join('&');
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* URL sem usuário/senha e com a query limpa (`/reset?token=[Filtered]&page=[Filtered]`). Vale para
|
|
520
|
+
* URL absoluta, caminho com query e texto que não é URL válida (a limpeza é textual, sem `new URL`).
|
|
521
|
+
* Fragmento com parâmetros (`#access_token=…`, `#/cb?code=…`) segue a mesma regra da query.
|
|
522
|
+
*/
|
|
523
|
+
export function scrubUrl(raw, options) {
|
|
524
|
+
let base = raw;
|
|
525
|
+
let fragment = null;
|
|
526
|
+
const hash = raw.indexOf('#');
|
|
527
|
+
if (hash >= 0) {
|
|
528
|
+
base = raw.slice(0, hash);
|
|
529
|
+
fragment = raw.slice(hash + 1);
|
|
530
|
+
}
|
|
531
|
+
// Usuário e senha vão até o último `@` da autoridade (o parser de URL aceita `@` na senha).
|
|
532
|
+
base = base.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/?#\s]*@/, '$1');
|
|
533
|
+
const q = base.indexOf('?');
|
|
534
|
+
let out = q >= 0 ? `${base.slice(0, q)}?${scrubQuery(base.slice(q + 1), options)}` : base;
|
|
535
|
+
if (fragment !== null) {
|
|
536
|
+
const fq = fragment.indexOf('?');
|
|
537
|
+
if (fq >= 0)
|
|
538
|
+
out += `#${fragment.slice(0, fq)}?${scrubQuery(fragment.slice(fq + 1), options)}`;
|
|
539
|
+
else if (fragment.includes('='))
|
|
540
|
+
out += `#${scrubQuery(fragment, options)}`;
|
|
541
|
+
else
|
|
542
|
+
out += `#${fragment}`;
|
|
543
|
+
}
|
|
544
|
+
return options.fields.length > 0 ? scrubPatterns(out, options) : out;
|
|
545
|
+
}
|
|
546
|
+
/* ------------------------------------------------------------------ */
|
|
547
|
+
/* Objetos */
|
|
548
|
+
/* ------------------------------------------------------------------ */
|
|
549
|
+
function scrubString(key, value, options) {
|
|
550
|
+
if (key !== null && URL_KEYS.has(key))
|
|
551
|
+
return scrubUrl(value, options);
|
|
552
|
+
if (options.fields.length === 0)
|
|
553
|
+
return value;
|
|
554
|
+
if (key !== null && (VALUE_EXEMPT_KEYS.has(key) || isIdentifierKey(key)))
|
|
555
|
+
return value;
|
|
556
|
+
return scrubText(value, options);
|
|
14
557
|
}
|
|
15
|
-
|
|
16
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Cópia limpa até `MAX_DEPTH`. `ancestors` guarda o ramo atual: um ciclo vira `[Circular]` na
|
|
560
|
+
* primeira repetição (como na serialização), e o mesmo objeto em dois ramos sai inteiro nos dois.
|
|
561
|
+
*/
|
|
562
|
+
export function scrubValue(value, options, depth = 0, key = null, ancestors = [], fromToJSON = false) {
|
|
563
|
+
if (typeof value === 'string')
|
|
564
|
+
return scrubString(key, value, options);
|
|
565
|
+
// Função (também a devolvida por um `toJSON`): o JSON a omite, e nenhum `toJSON` dela pode chegar à cópia.
|
|
566
|
+
if (typeof value === 'function')
|
|
567
|
+
return undefined;
|
|
568
|
+
if (value === null || typeof value !== 'object')
|
|
17
569
|
return value;
|
|
18
|
-
|
|
19
|
-
|
|
570
|
+
// String, Number e Boolean em caixa saem como o primitivo (como no JSON.stringify), não como um
|
|
571
|
+
// objeto de caracteres soltos que a limpeza por valor não reconheceria.
|
|
572
|
+
const boxed = unboxed(value);
|
|
573
|
+
if (boxed !== NOT_BOXED)
|
|
574
|
+
return scrubValue(boxed, options, depth, key, ancestors);
|
|
575
|
+
if (ancestors.includes(value))
|
|
576
|
+
return '[Circular]';
|
|
577
|
+
if (depth > MAX_DEPTH)
|
|
578
|
+
return MAX_DEPTH_MARKER;
|
|
579
|
+
// Date, Buffer e afins saem pelo `toJSON`, como sairiam na serialização: a cópia não os achata em `{}`.
|
|
580
|
+
// Como no JSON.stringify, o `toJSON` do resultado não é chamado de novo: um `toJSON` que devolve
|
|
581
|
+
// `this` (ou outro objeto) tem as propriedades limpas como qualquer objeto, nunca passa cru.
|
|
582
|
+
// Um `toJSON` que lança vira o mesmo marcador da serialização, sem perder o evento.
|
|
583
|
+
if (!fromToJSON && typeof value.toJSON === 'function') {
|
|
584
|
+
let json;
|
|
585
|
+
try {
|
|
586
|
+
json = value.toJSON();
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return '[Unserializable]';
|
|
590
|
+
}
|
|
591
|
+
return scrubValue(json, options, depth + 1, key, ancestors, true);
|
|
592
|
+
}
|
|
593
|
+
// Funções não vão para a cópia (o JSON.stringify também não as serializa): um `toJSON` próprio e
|
|
594
|
+
// enumerável copiado seria chamado de novo na serialização e poderia devolver o objeto original.
|
|
595
|
+
// A cópia é sempre um Array ou objeto simples: `map` de uma subclasse de Array (ou um objeto com
|
|
596
|
+
// protótipo próprio) levaria junto um `toJSON` herdado, chamado de novo na serialização.
|
|
597
|
+
const branch = [...ancestors, value];
|
|
598
|
+
if (Array.isArray(value)) {
|
|
599
|
+
const list = new Array(value.length);
|
|
600
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
601
|
+
const item = value[i];
|
|
602
|
+
list[i] = typeof item === 'function' ? null : scrubValue(item, options, depth + 1, key, branch);
|
|
603
|
+
}
|
|
604
|
+
return list;
|
|
605
|
+
}
|
|
20
606
|
const out = {};
|
|
21
|
-
for (const [
|
|
22
|
-
|
|
607
|
+
for (const [name, item] of Object.entries(value)) {
|
|
608
|
+
if (typeof item === 'function')
|
|
609
|
+
continue;
|
|
610
|
+
if (name === '__proto__') {
|
|
611
|
+
// Chave própria vinda de JSON.parse: atribuir trocaria o protótipo da cópia.
|
|
612
|
+
Object.defineProperty(out, name, { value: scrubValue(item, options, depth + 1, name, branch), enumerable: true, writable: true, configurable: true });
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
out[name] =
|
|
616
|
+
options.fields.length > 0 && shouldScrub(name, options.fields) && item !== null && item !== undefined
|
|
617
|
+
? FILTERED
|
|
618
|
+
: scrubValue(item, options, depth + 1, name, branch);
|
|
23
619
|
}
|
|
24
620
|
return out;
|
|
25
621
|
}
|
|
26
|
-
|
|
27
|
-
|
|
622
|
+
const NOT_BOXED = Symbol('not-boxed');
|
|
623
|
+
/** Primitivo de um String/Number/Boolean em caixa; `NOT_BOXED` para os demais objetos. */
|
|
624
|
+
function unboxed(value) {
|
|
625
|
+
try {
|
|
626
|
+
if (value instanceof String)
|
|
627
|
+
return String.prototype.valueOf.call(value);
|
|
628
|
+
if (value instanceof Number)
|
|
629
|
+
return Number.prototype.valueOf.call(value);
|
|
630
|
+
if (value instanceof Boolean)
|
|
631
|
+
return Boolean.prototype.valueOf.call(value);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* objeto com o protótipo trocado: segue como objeto */
|
|
635
|
+
}
|
|
636
|
+
return NOT_BOXED;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Atributos limpos por chave, por valor e com as URLs limpas. Com `fields` vazio (`scrubFields:
|
|
640
|
+
* false`), só as URLs passam pela regra da query (que depende de `sendDefaultPii`).
|
|
641
|
+
*/
|
|
642
|
+
export function scrubAttributes(attributes, fields, options = {}) {
|
|
643
|
+
const resolved = { fields, sendDefaultPii: options.sendDefaultPii ?? false };
|
|
644
|
+
if (fields.length === 0) {
|
|
645
|
+
if (resolved.sendDefaultPii)
|
|
646
|
+
return attributes;
|
|
647
|
+
let changed = null;
|
|
648
|
+
for (const key of URL_KEYS) {
|
|
649
|
+
const value = attributes[key];
|
|
650
|
+
if (typeof value !== 'string')
|
|
651
|
+
continue;
|
|
652
|
+
const cleaned = scrubUrl(value, resolved);
|
|
653
|
+
if (cleaned !== value)
|
|
654
|
+
(changed ??= { ...attributes })[key] = cleaned;
|
|
655
|
+
}
|
|
656
|
+
return changed ?? attributes;
|
|
657
|
+
}
|
|
658
|
+
return scrubValue(attributes, resolved);
|
|
659
|
+
}
|
|
660
|
+
/** Mensagem e stack de um erro limpos por valor (o nome fica: é o tipo do erro). */
|
|
661
|
+
export function scrubError(error, options) {
|
|
662
|
+
if (options.fields.length === 0)
|
|
663
|
+
return error;
|
|
664
|
+
const message = scrubText(String(error.message ?? ''), options);
|
|
665
|
+
const stack = typeof error.stack === 'string' ? scrubText(error.stack, options) : undefined;
|
|
666
|
+
if (message === error.message && stack === error.stack)
|
|
667
|
+
return error;
|
|
668
|
+
return { ...error, message, ...(stack !== undefined ? { stack } : {}) };
|
|
669
|
+
}
|
|
670
|
+
/** Texto de diagnóstico (console): sempre limpo com a lista padrão, mesmo com `scrubFields: false`. */
|
|
671
|
+
export function scrubDiagnostic(text) {
|
|
672
|
+
return scrubText(text, { fields: DEFAULT_SCRUB_FIELDS, sendDefaultPii: false });
|
|
28
673
|
}
|
|
29
674
|
export function matchesAny(value, patterns) {
|
|
30
675
|
if (!patterns || patterns.length === 0)
|