@gyramais/log-redaction 0.1.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/README.md +39 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +274 -0
- package/dist/index.js.map +1 -0
- package/package.json +27 -0
- package/src/index.ts +339 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @gyramais/log-redaction
|
|
2
|
+
|
|
3
|
+
O que **não pode sair** no log dos serviços `gyra-*`: quais chaves são credencial,
|
|
4
|
+
quais são dado pessoal, quais são ambíguas e decididas pelo valor, e como cada
|
|
5
|
+
categoria é mascarada em body, headers e query string.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
maskSensitive,
|
|
10
|
+
maskUrl,
|
|
11
|
+
pickSafeHeaders,
|
|
12
|
+
describeError,
|
|
13
|
+
} from '@gyramais/log-redaction';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Esses quatro são os usados por **todas as 9 variantes** da classe
|
|
17
|
+
`LoggingInterceptor` na frota. Os predicados (`isSensitive`, `isPii`,
|
|
18
|
+
`isAmbiguousPii`, `isOauthCallbackOnly`, `isOidcCallbackShape`, `normalizeKey`,
|
|
19
|
+
`looksPersonal`, `maskPii`) e as constantes também são exportados, para quem
|
|
20
|
+
precise compor caso específico ou testar direto.
|
|
21
|
+
|
|
22
|
+
Sem dependência de runtime.
|
|
23
|
+
|
|
24
|
+
## Proveniência
|
|
25
|
+
|
|
26
|
+
`src/index.ts` é **cópia byte-a-byte** do bloco que existia duplicado e idêntico
|
|
27
|
+
nos 15 serviços após a GYR-1541 (md5 `cc04916718f5db4c776d256fc05068b3`, 313
|
|
28
|
+
linhas). A única diferença é a palavra `export`.
|
|
29
|
+
|
|
30
|
+
O arquivo está no `.prettierignore` de propósito: formatá-lo destruiria a
|
|
31
|
+
propriedade que torna a migração verificável — durante a adoção, cada serviço pode
|
|
32
|
+
conferir que o bloco que está removendo é exatamente o que o pacote passa a
|
|
33
|
+
fornecer.
|
|
34
|
+
|
|
35
|
+
Os comentários registram seis rodadas de revisão e três revisões humanas: por que
|
|
36
|
+
allowlist e não denylist, por que `documentType` fica fora, por que `to` é
|
|
37
|
+
decidido por valor e não por chave, por que `state` só é mascarado no body sob
|
|
38
|
+
assinatura de callback OIDC, por que `some` e não `every`, e por que o `seen` sai
|
|
39
|
+
do conjunto ao desempilhar. **Não remover.**
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'http';
|
|
2
|
+
export declare const SAFE_HEADERS: string[];
|
|
3
|
+
export declare const SENSITIVE_FIELDS: string[];
|
|
4
|
+
export declare const OAUTH_CALLBACK_ONLY_FIELDS: string[];
|
|
5
|
+
export declare const OIDC_CALLBACK_MARKERS: string[];
|
|
6
|
+
export declare const PII_FIELDS: string[];
|
|
7
|
+
export declare const AMBIGUOUS_PII_FIELDS: string[];
|
|
8
|
+
export declare const LOOKS_PERSONAL: RegExp;
|
|
9
|
+
export declare const FORMATTING_NOISE: RegExp;
|
|
10
|
+
export declare const looksPersonal: (value: unknown) => boolean;
|
|
11
|
+
export declare const REDACTED = "****";
|
|
12
|
+
export declare const TRUNCATED = "[profundidade m\u00E1xima]";
|
|
13
|
+
export declare const CIRCULAR = "[circular]";
|
|
14
|
+
export declare const MAX_MASK_DEPTH = 4;
|
|
15
|
+
export declare const normalizeKey: (key: string) => string;
|
|
16
|
+
export declare const isSensitive: (key: string) => boolean;
|
|
17
|
+
export declare const isPii: (key: string) => boolean;
|
|
18
|
+
export declare const isAmbiguousPii: (key: string, value: unknown) => boolean;
|
|
19
|
+
export declare const isOauthCallbackOnly: (key: string) => boolean;
|
|
20
|
+
export declare const isOidcCallbackShape: (obj: Record<string, unknown>) => boolean;
|
|
21
|
+
export declare const maskPii: (value: unknown) => unknown;
|
|
22
|
+
export declare const pickSafeHeaders: (headers: IncomingHttpHeaders | undefined) => Record<string, unknown>;
|
|
23
|
+
export declare const maskSensitive: <T>(value: T, depth?: number, seen?: WeakSet<object>) => T;
|
|
24
|
+
export declare const maskUrl: (url: string) => string;
|
|
25
|
+
export declare const describeError: (err: unknown) => {
|
|
26
|
+
name?: string;
|
|
27
|
+
message: string;
|
|
28
|
+
stack?: string;
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAQhD,eAAO,MAAM,YAAY,UAMxB,CAAC;AAGF,eAAO,MAAM,gBAAgB,UAuB5B,CAAC;AAWF,eAAO,MAAM,0BAA0B,UAAY,CAAC;AAGpD,eAAO,MAAM,qBAAqB,UAAsB,CAAC;AAgBzD,eAAO,MAAM,UAAU,UActB,CAAC;AAQF,eAAO,MAAM,oBAAoB,UAAS,CAAC;AAM3C,eAAO,MAAM,cAAc,QAAqB,CAAC;AAKjD,eAAO,MAAM,gBAAgB,QAAa,CAAC;AAE3C,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,OAEW,CAAC;AAE3D,eAAO,MAAM,QAAQ,SAAS,CAAC;AAC/B,eAAO,MAAM,SAAS,+BAA0B,CAAC;AACjD,eAAO,MAAM,QAAQ,eAAe,CAAC;AAIrC,eAAO,MAAM,cAAc,IAAI,CAAC;AAOhC,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,MACH,CAAC;AAUzC,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,OACH,CAAC;AACxC,eAAO,MAAM,KAAK,GAAI,KAAK,MAAM,KAAG,OAA0C,CAAC;AAY/E,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,OAAO,OAAO,KAAG,OAEkB,CAAC;AAEhF,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,KAAG,OACD,CAAC;AAElD,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,OACM,CAAC;AAK1E,eAAO,MAAM,OAAO,GAAI,OAAO,OAAO,KAAG,OAgBxC,CAAC;AAIF,eAAO,MAAM,eAAe,GAC1B,SAAS,mBAAmB,GAAG,SAAS,KACvC,MAAM,CAAC,MAAM,EAAE,OAAO,CAcxB,CAAC;AAcF,eAAO,MAAM,aAAa,GAAI,CAAC,EAC7B,OAAO,CAAC,EACR,cAAS,EACT,sBAA4B,KAC3B,CA4CF,CAAC;AAMF,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,MA8BrC,CAAC;AAOF,eAAO,MAAM,aAAa,GACxB,KAAK,OAAO,KACX;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAUlD,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.describeError = exports.maskUrl = exports.maskSensitive = exports.pickSafeHeaders = exports.maskPii = exports.isOidcCallbackShape = exports.isOauthCallbackOnly = exports.isAmbiguousPii = exports.isPii = exports.isSensitive = exports.normalizeKey = exports.MAX_MASK_DEPTH = exports.CIRCULAR = exports.TRUNCATED = exports.REDACTED = exports.looksPersonal = exports.FORMATTING_NOISE = exports.LOOKS_PERSONAL = exports.AMBIGUOUS_PII_FIELDS = exports.PII_FIELDS = exports.OIDC_CALLBACK_MARKERS = exports.OAUTH_CALLBACK_ONLY_FIELDS = exports.SENSITIVE_FIELDS = exports.SAFE_HEADERS = void 0;
|
|
5
|
+
// Allowlist: só o que está listado aqui chega ao log. Uma denylist
|
|
6
|
+
// (`delete headers.authorization`) falharia em silêncio no dia em que alguém
|
|
7
|
+
// adicionasse um header novo carregando credencial.
|
|
8
|
+
// Ficam de fora, por serem credencial viva: `authorization`, `cookie`,
|
|
9
|
+
// `gyra-client-secret`. O `gyra-client-id` também fica de fora: sozinho é só
|
|
10
|
+
// um identificador, mas emparelhado com o secret num mesmo log facilita ataque.
|
|
11
|
+
exports.SAFE_HEADERS = [
|
|
12
|
+
'x-amzn-trace-id',
|
|
13
|
+
'x-forwarded-for',
|
|
14
|
+
'user-agent',
|
|
15
|
+
'content-type',
|
|
16
|
+
'content-length',
|
|
17
|
+
];
|
|
18
|
+
// Credencial: o valor não tem utilidade nenhuma no log, vai inteiro para `****`.
|
|
19
|
+
exports.SENSITIVE_FIELDS = [
|
|
20
|
+
'password',
|
|
21
|
+
'token',
|
|
22
|
+
'accessToken',
|
|
23
|
+
'refreshToken',
|
|
24
|
+
'passwordResetToken',
|
|
25
|
+
'deletionToken',
|
|
26
|
+
'clientSecret',
|
|
27
|
+
'apiKey',
|
|
28
|
+
'apiSecret',
|
|
29
|
+
'secret',
|
|
30
|
+
'credentials',
|
|
31
|
+
'authorization',
|
|
32
|
+
// Fluxo OAuth: `code` é o authorization code, `idToken` o id token, `state`
|
|
33
|
+
// o nonce anti-CSRF — todos chegam por query no callback, que cai no
|
|
34
|
+
// interceptor global.
|
|
35
|
+
//
|
|
36
|
+
// `code` não é só OAuth: é o código de login de uso único do
|
|
37
|
+
// gyra-communication (`GoLoginCodePayload.code`, 6 dígitos, TTL de 5 min,
|
|
38
|
+
// lido de `payload.code` em `POST /communicate`). Nenhum DTO da plataforma
|
|
39
|
+
// usa `code` como campo não sensível.
|
|
40
|
+
'code',
|
|
41
|
+
'idToken',
|
|
42
|
+
];
|
|
43
|
+
// `state` é o nonce anti-CSRF do OAuth. Na query ele é sempre OAuth. No body só
|
|
44
|
+
// é OAuth no callback OIDC com `responseMode: 'form_post'` — o do Microsoft
|
|
45
|
+
// (`microsoft-oauth.strategy.ts:19`, `responseType: 'code id_token'`), numa rota
|
|
46
|
+
// `@Post` com `@Req()` e sem DTO, que é por isso que uma varredura por DTO não
|
|
47
|
+
// o enxerga. Fora dali, `state` é UF de endereço no domínio de bureau.
|
|
48
|
+
//
|
|
49
|
+
// No body, portanto, só mascara quando a assinatura do callback OIDC está
|
|
50
|
+
// presente: `code` ou `idToken` no mesmo objeto. Um payload de endereço não tem
|
|
51
|
+
// nenhum dos dois.
|
|
52
|
+
exports.OAUTH_CALLBACK_ONLY_FIELDS = ['state'];
|
|
53
|
+
// Presença destas chaves marca o objeto como callback OIDC.
|
|
54
|
+
exports.OIDC_CALLBACK_MARKERS = ['code', 'idToken'];
|
|
55
|
+
// Dado pessoal: categoria distinta de credencial. Não pode ir em plain text —
|
|
56
|
+
// requisito LGPD já vigente na plataforma, registrado em
|
|
57
|
+
// `gyra-integration/specs/001-scr-raw-data-api/spec.md:179` — mas, diferente de
|
|
58
|
+
// credencial, aqui o domínio do e-mail é preservado: ele não identifica pessoa e
|
|
59
|
+
// é o que diz de qual organização veio a chamada, a única atribuição que resta
|
|
60
|
+
// nos serviços sem guard.
|
|
61
|
+
//
|
|
62
|
+
// `username` está aqui porque no gyra-auth ele É o e-mail ou o CPF: o
|
|
63
|
+
// `IsUsername` valida `isEmail || isCPF`. Mascarar `email` e deixar `username`
|
|
64
|
+
// em claro não mascararia nada em login, signup e reset de senha.
|
|
65
|
+
//
|
|
66
|
+
// `documentType` e `documentTypes` NÃO entram: são enums (CPF/CNPJ), não
|
|
67
|
+
// valores. A comparação é por chave exata, então ficam de fora naturalmente —
|
|
68
|
+
// um match por substring em "document" os pegaria e perderia informação útil.
|
|
69
|
+
exports.PII_FIELDS = [
|
|
70
|
+
'email',
|
|
71
|
+
'signerEmail',
|
|
72
|
+
'username',
|
|
73
|
+
'cpf',
|
|
74
|
+
'cnpj',
|
|
75
|
+
'document',
|
|
76
|
+
'documents',
|
|
77
|
+
'newDocument',
|
|
78
|
+
'signerDocument',
|
|
79
|
+
'phone',
|
|
80
|
+
'phoneNumber',
|
|
81
|
+
'signerPhoneNumber',
|
|
82
|
+
'signerName',
|
|
83
|
+
];
|
|
84
|
+
// Chaves cujo significado varia entre os 15 serviços. Aqui a decisão é pelo
|
|
85
|
+
// VALOR, não pelo nome: `to` é destinatário de e-mail no gyra-communication, mas
|
|
86
|
+
// é data ISO em `UsageQueryDto.to` (gyra-billing) e limite de faixa numérica em
|
|
87
|
+
// `CreateDto.to: number` (gyra-core e gyra-credit-policy, 6 DTOs). Mascarar por
|
|
88
|
+
// nome apagaria o campo que se quer ver ao depurar faixa de risco ou intervalo
|
|
89
|
+
// de cobrança — a mesma disciplina já aplicada ao `documentType`.
|
|
90
|
+
exports.AMBIGUOUS_PII_FIELDS = ['to'];
|
|
91
|
+
// Casa e-mail e telefone; não casa data ISO nem número pequeno. Erra para o
|
|
92
|
+
// lado de mascarar: um epoch em milissegundos tem 13 dígitos e seria mascarado —
|
|
93
|
+
// aceitável, porque nenhum `to` da plataforma carrega epoch hoje e o custo de
|
|
94
|
+
// errar para o outro lado é vazar dado pessoal.
|
|
95
|
+
exports.LOOKS_PERSONAL = /@|^\+?\d{10,14}$/;
|
|
96
|
+
// Espaço, parêntese e hífen saem antes do teste, senão `(81) 99999-9999` passa.
|
|
97
|
+
// Isto não reabre o falso-positivo de data: `2026-01-31` sem hífen tem 8
|
|
98
|
+
// dígitos, abaixo do mínimo de 10.
|
|
99
|
+
exports.FORMATTING_NOISE = /[\s()-]/g;
|
|
100
|
+
const looksPersonal = (value) => typeof value === 'string' &&
|
|
101
|
+
exports.LOOKS_PERSONAL.test(value.replace(exports.FORMATTING_NOISE, ''));
|
|
102
|
+
exports.looksPersonal = looksPersonal;
|
|
103
|
+
exports.REDACTED = '****';
|
|
104
|
+
exports.TRUNCATED = '[profundidade máxima]';
|
|
105
|
+
exports.CIRCULAR = '[circular]';
|
|
106
|
+
// Profundidade máxima do mascaramento. Limita o custo por requisição e é o que
|
|
107
|
+
// impede laço infinito em objeto cíclico.
|
|
108
|
+
exports.MAX_MASK_DEPTH = 4;
|
|
109
|
+
// Comparação normalizada — minúsculas e sem separador. Sem isto a lista é uma
|
|
110
|
+
// denylist frágil: `Password`, `Token`, `Authorization`, `access_token`,
|
|
111
|
+
// `client_secret`, `api_key` e `refresh_token` passariam inteiros, por não
|
|
112
|
+
// baterem exatamente com o camelCase da lista. Nenhum DTO atual usa esses
|
|
113
|
+
// nomes, mas a lista não pode depender disso.
|
|
114
|
+
const normalizeKey = (key) => key.toLowerCase().replace(/[_-]/g, '');
|
|
115
|
+
exports.normalizeKey = normalizeKey;
|
|
116
|
+
const SENSITIVE_KEYS = new Set(exports.SENSITIVE_FIELDS.map(exports.normalizeKey));
|
|
117
|
+
const OAUTH_CALLBACK_ONLY_KEYS = new Set(exports.OAUTH_CALLBACK_ONLY_FIELDS.map(exports.normalizeKey));
|
|
118
|
+
const OIDC_MARKER_KEYS = new Set(exports.OIDC_CALLBACK_MARKERS.map(exports.normalizeKey));
|
|
119
|
+
const PII_KEYS = new Set(exports.PII_FIELDS.map(exports.normalizeKey));
|
|
120
|
+
const AMBIGUOUS_PII_KEYS = new Set(exports.AMBIGUOUS_PII_FIELDS.map(exports.normalizeKey));
|
|
121
|
+
const isSensitive = (key) => SENSITIVE_KEYS.has((0, exports.normalizeKey)(key));
|
|
122
|
+
exports.isSensitive = isSensitive;
|
|
123
|
+
const isPii = (key) => PII_KEYS.has((0, exports.normalizeKey)(key));
|
|
124
|
+
exports.isPii = isPii;
|
|
125
|
+
// Chave ambígua: só é PII se o valor parecer dado pessoal.
|
|
126
|
+
//
|
|
127
|
+
// O ramo de array é obrigatório, não conveniência: `InternalAlertDto.to` é
|
|
128
|
+
// `string | string[]` (`@IsArray()` + `@IsString({ each: true })`), e o
|
|
129
|
+
// controller exige `@` em todo destinatário. Sem ele o array cai no ramo comum
|
|
130
|
+
// do `maskSensitive`, que devolve as strings intactas.
|
|
131
|
+
//
|
|
132
|
+
// `some` e não `every`: numa lista mista basta um destinatário parecer pessoal
|
|
133
|
+
// para a lista inteira precisar de mascaramento, e o `maskPii` já preserva o
|
|
134
|
+
// domínio elemento a elemento.
|
|
135
|
+
const isAmbiguousPii = (key, value) => AMBIGUOUS_PII_KEYS.has((0, exports.normalizeKey)(key)) &&
|
|
136
|
+
((0, exports.looksPersonal)(value) || (Array.isArray(value) && value.some(exports.looksPersonal)));
|
|
137
|
+
exports.isAmbiguousPii = isAmbiguousPii;
|
|
138
|
+
const isOauthCallbackOnly = (key) => OAUTH_CALLBACK_ONLY_KEYS.has((0, exports.normalizeKey)(key));
|
|
139
|
+
exports.isOauthCallbackOnly = isOauthCallbackOnly;
|
|
140
|
+
const isOidcCallbackShape = (obj) => Object.keys(obj).some((key) => OIDC_MARKER_KEYS.has((0, exports.normalizeKey)(key)));
|
|
141
|
+
exports.isOidcCallbackShape = isOidcCallbackShape;
|
|
142
|
+
// `null`/`undefined` passam intactos, para a linha continuar dizendo que o campo
|
|
143
|
+
// veio vazio. Qualquer valor que não seja string vira `****` — não há como
|
|
144
|
+
// preservar domínio de um número ou de um objeto.
|
|
145
|
+
const maskPii = (value) => {
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
return value.map(exports.maskPii);
|
|
148
|
+
}
|
|
149
|
+
if (value === null || value === undefined) {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== 'string') {
|
|
153
|
+
return exports.REDACTED;
|
|
154
|
+
}
|
|
155
|
+
const at = value.lastIndexOf('@');
|
|
156
|
+
return at > 0 ? `${exports.REDACTED}${value.slice(at)}` : exports.REDACTED;
|
|
157
|
+
};
|
|
158
|
+
exports.maskPii = maskPii;
|
|
159
|
+
// Node normaliza os nomes dos headers de entrada para minúsculas em
|
|
160
|
+
// `req.headers`, então a comparação é direta.
|
|
161
|
+
const pickSafeHeaders = (headers) => {
|
|
162
|
+
const safeHeaders = {};
|
|
163
|
+
if (!headers) {
|
|
164
|
+
return safeHeaders;
|
|
165
|
+
}
|
|
166
|
+
for (const name of exports.SAFE_HEADERS) {
|
|
167
|
+
if (headers[name] !== undefined) {
|
|
168
|
+
safeHeaders[name] = headers[name];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return safeHeaders;
|
|
172
|
+
};
|
|
173
|
+
exports.pickSafeHeaders = pickSafeHeaders;
|
|
174
|
+
// Recursivo até `MAX_MASK_DEPTH`: nem todo DTO da plataforma tem o campo
|
|
175
|
+
// sensível na raiz — `credentials: { username, password }` e
|
|
176
|
+
// `payload: { passwordResetToken }` são reais e ficariam de fora de um
|
|
177
|
+
// mascaramento de um nível só.
|
|
178
|
+
//
|
|
179
|
+
// Além do limite, o subobjeto é substituído por um marcador em vez de voltar
|
|
180
|
+
// cru: se voltasse cru, um segredo mais fundo que o limite escaparia — que é
|
|
181
|
+
// exatamente o que esta função existe para impedir.
|
|
182
|
+
//
|
|
183
|
+
// O `seen` corta ciclo. Body vindo de `JSON.parse` é sempre árvore, mas
|
|
184
|
+
// payload de mensagem não precisa ser, e um ciclo aqui derrubaria a
|
|
185
|
+
// serialização do Winston em runtime, não só o mascaramento.
|
|
186
|
+
const maskSensitive = (value, depth = 0, seen = new WeakSet()) => {
|
|
187
|
+
if (!value || typeof value !== 'object') {
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
if (depth > exports.MAX_MASK_DEPTH) {
|
|
191
|
+
return exports.TRUNCATED;
|
|
192
|
+
}
|
|
193
|
+
if (seen.has(value)) {
|
|
194
|
+
return exports.CIRCULAR;
|
|
195
|
+
}
|
|
196
|
+
seen.add(value);
|
|
197
|
+
try {
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
return value.map((item) => (0, exports.maskSensitive)(item, depth + 1, seen));
|
|
200
|
+
}
|
|
201
|
+
const masked = {
|
|
202
|
+
...value,
|
|
203
|
+
};
|
|
204
|
+
const oidcCallback = (0, exports.isOidcCallbackShape)(masked);
|
|
205
|
+
for (const [key, nested] of Object.entries(masked)) {
|
|
206
|
+
if ((0, exports.isSensitive)(key) || (oidcCallback && (0, exports.isOauthCallbackOnly)(key))) {
|
|
207
|
+
masked[key] = exports.REDACTED;
|
|
208
|
+
}
|
|
209
|
+
else if ((0, exports.isPii)(key) || (0, exports.isAmbiguousPii)(key, nested)) {
|
|
210
|
+
masked[key] = (0, exports.maskPii)(nested);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
masked[key] = (0, exports.maskSensitive)(nested, depth + 1, seen);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return masked;
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
// Sai do `seen` ao desempilhar: o conjunto passa a representar o caminho
|
|
220
|
+
// atual, não tudo que já foi visitado. Sem isto, `{ a: shared, b: shared }`
|
|
221
|
+
// — mesmo objeto referenciado duas vezes, sem ciclo nenhum — saía com
|
|
222
|
+
// `b: "[circular]"`.
|
|
223
|
+
seen.delete(value);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
exports.maskSensitive = maskSensitive;
|
|
227
|
+
// `request.url` carrega a query string, e há rota que recebe token por query
|
|
228
|
+
// (`GET /user?passwordResetToken=…`) e rota que recebe o próprio username
|
|
229
|
+
// (`FindUserDto.username` em `GET /user`). Mascara o valor e preserva a chave,
|
|
230
|
+
// para a linha continuar dizendo qual parâmetro veio.
|
|
231
|
+
const maskUrl = (url) => {
|
|
232
|
+
if (typeof url !== 'string') {
|
|
233
|
+
return url;
|
|
234
|
+
}
|
|
235
|
+
const separator = url.indexOf('?');
|
|
236
|
+
if (separator === -1) {
|
|
237
|
+
return url;
|
|
238
|
+
}
|
|
239
|
+
const path = url.slice(0, separator);
|
|
240
|
+
const params = new URLSearchParams(url.slice(separator + 1));
|
|
241
|
+
const masked = new URLSearchParams();
|
|
242
|
+
// Percorre par a par e reconstrói: `params.set` mantinha só a primeira
|
|
243
|
+
// ocorrência da chave e descartava o resto, então `?document=111&document=222`
|
|
244
|
+
// saía como `document=****` — a linha perdia a informação de que vieram dois
|
|
245
|
+
// valores.
|
|
246
|
+
for (const [key, value] of params) {
|
|
247
|
+
if ((0, exports.isSensitive)(key) || (0, exports.isOauthCallbackOnly)(key)) {
|
|
248
|
+
masked.append(key, exports.REDACTED);
|
|
249
|
+
}
|
|
250
|
+
else if ((0, exports.isPii)(key) || (0, exports.isAmbiguousPii)(key, value)) {
|
|
251
|
+
masked.append(key, String((0, exports.maskPii)(value)));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
masked.append(key, value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return `${path}?${masked.toString()}`;
|
|
258
|
+
};
|
|
259
|
+
exports.maskUrl = maskUrl;
|
|
260
|
+
// Nunca o objeto de erro inteiro: um AxiosError carrega
|
|
261
|
+
// `config.headers.Authorization`. `name` e `stack` não carregam header nenhum
|
|
262
|
+
// e são o que torna a linha acionável para quem está de plantão.
|
|
263
|
+
// O `String(err)` cobre rejeição que não é `Error` — sem ele a linha sairia
|
|
264
|
+
// com `error: undefined` e o operador não teria nada.
|
|
265
|
+
const describeError = (err) => {
|
|
266
|
+
const error = err;
|
|
267
|
+
return {
|
|
268
|
+
name: error?.name,
|
|
269
|
+
message: error?.message ?? String(err),
|
|
270
|
+
stack: error?.stack,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
exports.describeError = describeError;
|
|
274
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gyramais/log-redaction",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mascaramento de credencial e dado pessoal no log dos servicos gyra-*",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/gyramais/gyra-libs.git",
|
|
17
|
+
"directory": "packages/log-redaction"
|
|
18
|
+
},
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "rm -rf dist && tsc -p tsconfig.json && node ../../scripts/verify-build.js",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "jest --randomize",
|
|
24
|
+
"test:cov": "jest --coverage",
|
|
25
|
+
"prepack": "npm run build"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Padrao de mascaramento de log dos servicos gyra-*.
|
|
5
|
+
|
|
6
|
+
PROVENIENCIA: extraido byte a byte de
|
|
7
|
+
gyra-core/src/common/interceptors/logging-interceptor.ts apos a GYR-1541, onde o
|
|
8
|
+
mesmo bloco existia duplicado e identico nos 15 servicos
|
|
9
|
+
(md5 cc04916718f5db4c776d256fc05068b3, 313 linhas, conferido nos 15 em main).
|
|
10
|
+
|
|
11
|
+
A unica alteracao em relacao a fonte e a visibilidade: as declaracoes ganharam
|
|
12
|
+
`export`. Nenhuma assinatura, nenhum comportamento e nenhum comentario foi
|
|
13
|
+
alterado — os comentarios registram seis rodadas de revisao e tres revisoes
|
|
14
|
+
humanas, e explicam decisoes que ja foram questionadas e defendidas.
|
|
15
|
+
|
|
16
|
+
API que os servicos consomem: maskSensitive, maskUrl, pickSafeHeaders,
|
|
17
|
+
describeError — os quatro usados por todas as 9 variantes da classe
|
|
18
|
+
LoggingInterceptor. O resto e exportado para a suite exercitar cada predicado
|
|
19
|
+
direto, em vez de dirigir o interceptor.
|
|
20
|
+
|
|
21
|
+
A classe LoggingInterceptor NAO faz parte deste pacote: sao 9 variantes entre os
|
|
22
|
+
15 servicos, e cada um mantem a sua.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { IncomingHttpHeaders } from 'http';
|
|
26
|
+
|
|
27
|
+
// Allowlist: só o que está listado aqui chega ao log. Uma denylist
|
|
28
|
+
// (`delete headers.authorization`) falharia em silêncio no dia em que alguém
|
|
29
|
+
// adicionasse um header novo carregando credencial.
|
|
30
|
+
// Ficam de fora, por serem credencial viva: `authorization`, `cookie`,
|
|
31
|
+
// `gyra-client-secret`. O `gyra-client-id` também fica de fora: sozinho é só
|
|
32
|
+
// um identificador, mas emparelhado com o secret num mesmo log facilita ataque.
|
|
33
|
+
export const SAFE_HEADERS = [
|
|
34
|
+
'x-amzn-trace-id',
|
|
35
|
+
'x-forwarded-for',
|
|
36
|
+
'user-agent',
|
|
37
|
+
'content-type',
|
|
38
|
+
'content-length',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Credencial: o valor não tem utilidade nenhuma no log, vai inteiro para `****`.
|
|
42
|
+
export const SENSITIVE_FIELDS = [
|
|
43
|
+
'password',
|
|
44
|
+
'token',
|
|
45
|
+
'accessToken',
|
|
46
|
+
'refreshToken',
|
|
47
|
+
'passwordResetToken',
|
|
48
|
+
'deletionToken',
|
|
49
|
+
'clientSecret',
|
|
50
|
+
'apiKey',
|
|
51
|
+
'apiSecret',
|
|
52
|
+
'secret',
|
|
53
|
+
'credentials',
|
|
54
|
+
'authorization',
|
|
55
|
+
// Fluxo OAuth: `code` é o authorization code, `idToken` o id token, `state`
|
|
56
|
+
// o nonce anti-CSRF — todos chegam por query no callback, que cai no
|
|
57
|
+
// interceptor global.
|
|
58
|
+
//
|
|
59
|
+
// `code` não é só OAuth: é o código de login de uso único do
|
|
60
|
+
// gyra-communication (`GoLoginCodePayload.code`, 6 dígitos, TTL de 5 min,
|
|
61
|
+
// lido de `payload.code` em `POST /communicate`). Nenhum DTO da plataforma
|
|
62
|
+
// usa `code` como campo não sensível.
|
|
63
|
+
'code',
|
|
64
|
+
'idToken',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// `state` é o nonce anti-CSRF do OAuth. Na query ele é sempre OAuth. No body só
|
|
68
|
+
// é OAuth no callback OIDC com `responseMode: 'form_post'` — o do Microsoft
|
|
69
|
+
// (`microsoft-oauth.strategy.ts:19`, `responseType: 'code id_token'`), numa rota
|
|
70
|
+
// `@Post` com `@Req()` e sem DTO, que é por isso que uma varredura por DTO não
|
|
71
|
+
// o enxerga. Fora dali, `state` é UF de endereço no domínio de bureau.
|
|
72
|
+
//
|
|
73
|
+
// No body, portanto, só mascara quando a assinatura do callback OIDC está
|
|
74
|
+
// presente: `code` ou `idToken` no mesmo objeto. Um payload de endereço não tem
|
|
75
|
+
// nenhum dos dois.
|
|
76
|
+
export const OAUTH_CALLBACK_ONLY_FIELDS = ['state'];
|
|
77
|
+
|
|
78
|
+
// Presença destas chaves marca o objeto como callback OIDC.
|
|
79
|
+
export const OIDC_CALLBACK_MARKERS = ['code', 'idToken'];
|
|
80
|
+
|
|
81
|
+
// Dado pessoal: categoria distinta de credencial. Não pode ir em plain text —
|
|
82
|
+
// requisito LGPD já vigente na plataforma, registrado em
|
|
83
|
+
// `gyra-integration/specs/001-scr-raw-data-api/spec.md:179` — mas, diferente de
|
|
84
|
+
// credencial, aqui o domínio do e-mail é preservado: ele não identifica pessoa e
|
|
85
|
+
// é o que diz de qual organização veio a chamada, a única atribuição que resta
|
|
86
|
+
// nos serviços sem guard.
|
|
87
|
+
//
|
|
88
|
+
// `username` está aqui porque no gyra-auth ele É o e-mail ou o CPF: o
|
|
89
|
+
// `IsUsername` valida `isEmail || isCPF`. Mascarar `email` e deixar `username`
|
|
90
|
+
// em claro não mascararia nada em login, signup e reset de senha.
|
|
91
|
+
//
|
|
92
|
+
// `documentType` e `documentTypes` NÃO entram: são enums (CPF/CNPJ), não
|
|
93
|
+
// valores. A comparação é por chave exata, então ficam de fora naturalmente —
|
|
94
|
+
// um match por substring em "document" os pegaria e perderia informação útil.
|
|
95
|
+
export const PII_FIELDS = [
|
|
96
|
+
'email',
|
|
97
|
+
'signerEmail',
|
|
98
|
+
'username',
|
|
99
|
+
'cpf',
|
|
100
|
+
'cnpj',
|
|
101
|
+
'document',
|
|
102
|
+
'documents',
|
|
103
|
+
'newDocument',
|
|
104
|
+
'signerDocument',
|
|
105
|
+
'phone',
|
|
106
|
+
'phoneNumber',
|
|
107
|
+
'signerPhoneNumber',
|
|
108
|
+
'signerName',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// Chaves cujo significado varia entre os 15 serviços. Aqui a decisão é pelo
|
|
112
|
+
// VALOR, não pelo nome: `to` é destinatário de e-mail no gyra-communication, mas
|
|
113
|
+
// é data ISO em `UsageQueryDto.to` (gyra-billing) e limite de faixa numérica em
|
|
114
|
+
// `CreateDto.to: number` (gyra-core e gyra-credit-policy, 6 DTOs). Mascarar por
|
|
115
|
+
// nome apagaria o campo que se quer ver ao depurar faixa de risco ou intervalo
|
|
116
|
+
// de cobrança — a mesma disciplina já aplicada ao `documentType`.
|
|
117
|
+
export const AMBIGUOUS_PII_FIELDS = ['to'];
|
|
118
|
+
|
|
119
|
+
// Casa e-mail e telefone; não casa data ISO nem número pequeno. Erra para o
|
|
120
|
+
// lado de mascarar: um epoch em milissegundos tem 13 dígitos e seria mascarado —
|
|
121
|
+
// aceitável, porque nenhum `to` da plataforma carrega epoch hoje e o custo de
|
|
122
|
+
// errar para o outro lado é vazar dado pessoal.
|
|
123
|
+
export const LOOKS_PERSONAL = /@|^\+?\d{10,14}$/;
|
|
124
|
+
|
|
125
|
+
// Espaço, parêntese e hífen saem antes do teste, senão `(81) 99999-9999` passa.
|
|
126
|
+
// Isto não reabre o falso-positivo de data: `2026-01-31` sem hífen tem 8
|
|
127
|
+
// dígitos, abaixo do mínimo de 10.
|
|
128
|
+
export const FORMATTING_NOISE = /[\s()-]/g;
|
|
129
|
+
|
|
130
|
+
export const looksPersonal = (value: unknown): boolean =>
|
|
131
|
+
typeof value === 'string' &&
|
|
132
|
+
LOOKS_PERSONAL.test(value.replace(FORMATTING_NOISE, ''));
|
|
133
|
+
|
|
134
|
+
export const REDACTED = '****';
|
|
135
|
+
export const TRUNCATED = '[profundidade máxima]';
|
|
136
|
+
export const CIRCULAR = '[circular]';
|
|
137
|
+
|
|
138
|
+
// Profundidade máxima do mascaramento. Limita o custo por requisição e é o que
|
|
139
|
+
// impede laço infinito em objeto cíclico.
|
|
140
|
+
export const MAX_MASK_DEPTH = 4;
|
|
141
|
+
|
|
142
|
+
// Comparação normalizada — minúsculas e sem separador. Sem isto a lista é uma
|
|
143
|
+
// denylist frágil: `Password`, `Token`, `Authorization`, `access_token`,
|
|
144
|
+
// `client_secret`, `api_key` e `refresh_token` passariam inteiros, por não
|
|
145
|
+
// baterem exatamente com o camelCase da lista. Nenhum DTO atual usa esses
|
|
146
|
+
// nomes, mas a lista não pode depender disso.
|
|
147
|
+
export const normalizeKey = (key: string): string =>
|
|
148
|
+
key.toLowerCase().replace(/[_-]/g, '');
|
|
149
|
+
|
|
150
|
+
const SENSITIVE_KEYS = new Set(SENSITIVE_FIELDS.map(normalizeKey));
|
|
151
|
+
const OAUTH_CALLBACK_ONLY_KEYS = new Set(
|
|
152
|
+
OAUTH_CALLBACK_ONLY_FIELDS.map(normalizeKey),
|
|
153
|
+
);
|
|
154
|
+
const OIDC_MARKER_KEYS = new Set(OIDC_CALLBACK_MARKERS.map(normalizeKey));
|
|
155
|
+
const PII_KEYS = new Set(PII_FIELDS.map(normalizeKey));
|
|
156
|
+
const AMBIGUOUS_PII_KEYS = new Set(AMBIGUOUS_PII_FIELDS.map(normalizeKey));
|
|
157
|
+
|
|
158
|
+
export const isSensitive = (key: string): boolean =>
|
|
159
|
+
SENSITIVE_KEYS.has(normalizeKey(key));
|
|
160
|
+
export const isPii = (key: string): boolean => PII_KEYS.has(normalizeKey(key));
|
|
161
|
+
|
|
162
|
+
// Chave ambígua: só é PII se o valor parecer dado pessoal.
|
|
163
|
+
//
|
|
164
|
+
// O ramo de array é obrigatório, não conveniência: `InternalAlertDto.to` é
|
|
165
|
+
// `string | string[]` (`@IsArray()` + `@IsString({ each: true })`), e o
|
|
166
|
+
// controller exige `@` em todo destinatário. Sem ele o array cai no ramo comum
|
|
167
|
+
// do `maskSensitive`, que devolve as strings intactas.
|
|
168
|
+
//
|
|
169
|
+
// `some` e não `every`: numa lista mista basta um destinatário parecer pessoal
|
|
170
|
+
// para a lista inteira precisar de mascaramento, e o `maskPii` já preserva o
|
|
171
|
+
// domínio elemento a elemento.
|
|
172
|
+
export const isAmbiguousPii = (key: string, value: unknown): boolean =>
|
|
173
|
+
AMBIGUOUS_PII_KEYS.has(normalizeKey(key)) &&
|
|
174
|
+
(looksPersonal(value) || (Array.isArray(value) && value.some(looksPersonal)));
|
|
175
|
+
|
|
176
|
+
export const isOauthCallbackOnly = (key: string): boolean =>
|
|
177
|
+
OAUTH_CALLBACK_ONLY_KEYS.has(normalizeKey(key));
|
|
178
|
+
|
|
179
|
+
export const isOidcCallbackShape = (obj: Record<string, unknown>): boolean =>
|
|
180
|
+
Object.keys(obj).some((key) => OIDC_MARKER_KEYS.has(normalizeKey(key)));
|
|
181
|
+
|
|
182
|
+
// `null`/`undefined` passam intactos, para a linha continuar dizendo que o campo
|
|
183
|
+
// veio vazio. Qualquer valor que não seja string vira `****` — não há como
|
|
184
|
+
// preservar domínio de um número ou de um objeto.
|
|
185
|
+
export const maskPii = (value: unknown): unknown => {
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
return value.map(maskPii);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (value === null || value === undefined) {
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof value !== 'string') {
|
|
195
|
+
return REDACTED;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const at = value.lastIndexOf('@');
|
|
199
|
+
|
|
200
|
+
return at > 0 ? `${REDACTED}${value.slice(at)}` : REDACTED;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Node normaliza os nomes dos headers de entrada para minúsculas em
|
|
204
|
+
// `req.headers`, então a comparação é direta.
|
|
205
|
+
export const pickSafeHeaders = (
|
|
206
|
+
headers: IncomingHttpHeaders | undefined,
|
|
207
|
+
): Record<string, unknown> => {
|
|
208
|
+
const safeHeaders: Record<string, unknown> = {};
|
|
209
|
+
|
|
210
|
+
if (!headers) {
|
|
211
|
+
return safeHeaders;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const name of SAFE_HEADERS) {
|
|
215
|
+
if (headers[name] !== undefined) {
|
|
216
|
+
safeHeaders[name] = headers[name];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return safeHeaders;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Recursivo até `MAX_MASK_DEPTH`: nem todo DTO da plataforma tem o campo
|
|
224
|
+
// sensível na raiz — `credentials: { username, password }` e
|
|
225
|
+
// `payload: { passwordResetToken }` são reais e ficariam de fora de um
|
|
226
|
+
// mascaramento de um nível só.
|
|
227
|
+
//
|
|
228
|
+
// Além do limite, o subobjeto é substituído por um marcador em vez de voltar
|
|
229
|
+
// cru: se voltasse cru, um segredo mais fundo que o limite escaparia — que é
|
|
230
|
+
// exatamente o que esta função existe para impedir.
|
|
231
|
+
//
|
|
232
|
+
// O `seen` corta ciclo. Body vindo de `JSON.parse` é sempre árvore, mas
|
|
233
|
+
// payload de mensagem não precisa ser, e um ciclo aqui derrubaria a
|
|
234
|
+
// serialização do Winston em runtime, não só o mascaramento.
|
|
235
|
+
export const maskSensitive = <T>(
|
|
236
|
+
value: T,
|
|
237
|
+
depth = 0,
|
|
238
|
+
seen = new WeakSet<object>(),
|
|
239
|
+
): T => {
|
|
240
|
+
if (!value || typeof value !== 'object') {
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (depth > MAX_MASK_DEPTH) {
|
|
245
|
+
return TRUNCATED as T;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (seen.has(value as object)) {
|
|
249
|
+
return CIRCULAR as T;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
seen.add(value as object);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return value.map((item) => maskSensitive(item, depth + 1, seen)) as T;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const masked: Record<string, unknown> = {
|
|
260
|
+
...(value as Record<string, unknown>),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const oidcCallback = isOidcCallbackShape(masked);
|
|
264
|
+
|
|
265
|
+
for (const [key, nested] of Object.entries(masked)) {
|
|
266
|
+
if (isSensitive(key) || (oidcCallback && isOauthCallbackOnly(key))) {
|
|
267
|
+
masked[key] = REDACTED;
|
|
268
|
+
} else if (isPii(key) || isAmbiguousPii(key, nested)) {
|
|
269
|
+
masked[key] = maskPii(nested);
|
|
270
|
+
} else {
|
|
271
|
+
masked[key] = maskSensitive(nested, depth + 1, seen);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return masked as T;
|
|
276
|
+
} finally {
|
|
277
|
+
// Sai do `seen` ao desempilhar: o conjunto passa a representar o caminho
|
|
278
|
+
// atual, não tudo que já foi visitado. Sem isto, `{ a: shared, b: shared }`
|
|
279
|
+
// — mesmo objeto referenciado duas vezes, sem ciclo nenhum — saía com
|
|
280
|
+
// `b: "[circular]"`.
|
|
281
|
+
seen.delete(value as object);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// `request.url` carrega a query string, e há rota que recebe token por query
|
|
286
|
+
// (`GET /user?passwordResetToken=…`) e rota que recebe o próprio username
|
|
287
|
+
// (`FindUserDto.username` em `GET /user`). Mascara o valor e preserva a chave,
|
|
288
|
+
// para a linha continuar dizendo qual parâmetro veio.
|
|
289
|
+
export const maskUrl = (url: string): string => {
|
|
290
|
+
if (typeof url !== 'string') {
|
|
291
|
+
return url;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const separator = url.indexOf('?');
|
|
295
|
+
|
|
296
|
+
if (separator === -1) {
|
|
297
|
+
return url;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const path = url.slice(0, separator);
|
|
301
|
+
const params = new URLSearchParams(url.slice(separator + 1));
|
|
302
|
+
const masked = new URLSearchParams();
|
|
303
|
+
|
|
304
|
+
// Percorre par a par e reconstrói: `params.set` mantinha só a primeira
|
|
305
|
+
// ocorrência da chave e descartava o resto, então `?document=111&document=222`
|
|
306
|
+
// saía como `document=****` — a linha perdia a informação de que vieram dois
|
|
307
|
+
// valores.
|
|
308
|
+
for (const [key, value] of params) {
|
|
309
|
+
if (isSensitive(key) || isOauthCallbackOnly(key)) {
|
|
310
|
+
masked.append(key, REDACTED);
|
|
311
|
+
} else if (isPii(key) || isAmbiguousPii(key, value)) {
|
|
312
|
+
masked.append(key, String(maskPii(value)));
|
|
313
|
+
} else {
|
|
314
|
+
masked.append(key, value);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return `${path}?${masked.toString()}`;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Nunca o objeto de erro inteiro: um AxiosError carrega
|
|
322
|
+
// `config.headers.Authorization`. `name` e `stack` não carregam header nenhum
|
|
323
|
+
// e são o que torna a linha acionável para quem está de plantão.
|
|
324
|
+
// O `String(err)` cobre rejeição que não é `Error` — sem ele a linha sairia
|
|
325
|
+
// com `error: undefined` e o operador não teria nada.
|
|
326
|
+
export const describeError = (
|
|
327
|
+
err: unknown,
|
|
328
|
+
): { name?: string; message: string; stack?: string } => {
|
|
329
|
+
const error = err as
|
|
330
|
+
| { name?: string; message?: string; stack?: string }
|
|
331
|
+
| undefined;
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
name: error?.name,
|
|
335
|
+
message: error?.message ?? String(err),
|
|
336
|
+
stack: error?.stack,
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
|