@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 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.**
@@ -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
+