@logtape/redaction 1.3.5 → 1.4.0-dev.408
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/deno.json +34 -0
- package/dist/field.cjs +9 -38
- package/dist/field.js +9 -38
- package/dist/field.js.map +1 -1
- package/dist/pattern.cjs +0 -10
- package/dist/pattern.d.cts.map +1 -1
- package/dist/pattern.d.ts.map +1 -1
- package/dist/pattern.js +0 -10
- package/dist/pattern.js.map +1 -1
- package/package.json +2 -5
- package/src/field.test.ts +584 -0
- package/src/field.ts +432 -0
- package/src/mod.ts +8 -0
- package/src/pattern.test.ts +407 -0
- package/src/pattern.ts +221 -0
- package/tsdown.config.ts +11 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { suite } from "@alinea/suite";
|
|
2
|
+
import type {
|
|
3
|
+
ConsoleFormatter,
|
|
4
|
+
LogRecord,
|
|
5
|
+
TextFormatter,
|
|
6
|
+
} from "@logtape/logtape";
|
|
7
|
+
import { assert } from "@std/assert/assert";
|
|
8
|
+
import { assertEquals } from "@std/assert/equals";
|
|
9
|
+
import { assertMatch } from "@std/assert/match";
|
|
10
|
+
import { assertThrows } from "@std/assert/throws";
|
|
11
|
+
import {
|
|
12
|
+
CREDIT_CARD_NUMBER_PATTERN,
|
|
13
|
+
EMAIL_ADDRESS_PATTERN,
|
|
14
|
+
JWT_PATTERN,
|
|
15
|
+
KR_RRN_PATTERN,
|
|
16
|
+
redactByPattern,
|
|
17
|
+
type RedactionPattern,
|
|
18
|
+
US_SSN_PATTERN,
|
|
19
|
+
} from "./pattern.ts";
|
|
20
|
+
|
|
21
|
+
const test = suite(import.meta);
|
|
22
|
+
|
|
23
|
+
test("EMAIL_ADDRESS_PATTERN", () => {
|
|
24
|
+
const { pattern, replacement } = EMAIL_ADDRESS_PATTERN;
|
|
25
|
+
|
|
26
|
+
// Test valid email addresses
|
|
27
|
+
const validEmails = [
|
|
28
|
+
"user@example.com",
|
|
29
|
+
"first.last@example.co.uk",
|
|
30
|
+
"user+tag@example.org",
|
|
31
|
+
"user123@sub.domain.com",
|
|
32
|
+
"user-name@example.com",
|
|
33
|
+
"user_name@example.com",
|
|
34
|
+
"user.name@example-domain.co",
|
|
35
|
+
// Ensure international domains work:
|
|
36
|
+
// cSpell: disable
|
|
37
|
+
"用户@例子.世界",
|
|
38
|
+
"пользователь@пример.рф",
|
|
39
|
+
// cSpell: enable
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const email of validEmails) {
|
|
43
|
+
assertMatch(email, pattern);
|
|
44
|
+
pattern.lastIndex = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Test replacements
|
|
48
|
+
assertEquals(
|
|
49
|
+
"Contact at user@example.com for more info.".replaceAll(
|
|
50
|
+
pattern,
|
|
51
|
+
replacement as string,
|
|
52
|
+
),
|
|
53
|
+
"Contact at REDACTED@EMAIL.ADDRESS for more info.",
|
|
54
|
+
);
|
|
55
|
+
assertEquals(
|
|
56
|
+
"My email is user@example.com".replaceAll(pattern, replacement as string),
|
|
57
|
+
"My email is REDACTED@EMAIL.ADDRESS",
|
|
58
|
+
);
|
|
59
|
+
assertEquals(
|
|
60
|
+
"Emails: user1@example.com and user2@example.org".replaceAll(
|
|
61
|
+
pattern,
|
|
62
|
+
replacement as string,
|
|
63
|
+
),
|
|
64
|
+
"Emails: REDACTED@EMAIL.ADDRESS and REDACTED@EMAIL.ADDRESS",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Ensure the global flag is set
|
|
68
|
+
assert(
|
|
69
|
+
pattern.global,
|
|
70
|
+
"EMAIL_ADDRESS_PATTERN should have the global flag set",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("CREDIT_CARD_NUMBER_PATTERN", () => {
|
|
75
|
+
const { pattern, replacement } = CREDIT_CARD_NUMBER_PATTERN;
|
|
76
|
+
|
|
77
|
+
// Test valid credit card numbers with dashes
|
|
78
|
+
assertMatch("1234-5678-9012-3456", pattern); // Regular 16-digit card
|
|
79
|
+
pattern.lastIndex = 0;
|
|
80
|
+
assertMatch("1234-5678-901234", pattern); // American Express format
|
|
81
|
+
pattern.lastIndex = 0;
|
|
82
|
+
|
|
83
|
+
// Test replacements
|
|
84
|
+
assertEquals(
|
|
85
|
+
"Card: 1234-5678-9012-3456".replaceAll(pattern, replacement as string),
|
|
86
|
+
"Card: XXXX-XXXX-XXXX-XXXX",
|
|
87
|
+
);
|
|
88
|
+
assertEquals(
|
|
89
|
+
"AmEx: 1234-5678-901234".replaceAll(pattern, replacement as string),
|
|
90
|
+
"AmEx: XXXX-XXXX-XXXX-XXXX",
|
|
91
|
+
);
|
|
92
|
+
assertEquals(
|
|
93
|
+
"Cards: 1234-5678-9012-3456 and 1234-5678-901234".replaceAll(
|
|
94
|
+
pattern,
|
|
95
|
+
replacement as string,
|
|
96
|
+
),
|
|
97
|
+
"Cards: XXXX-XXXX-XXXX-XXXX and XXXX-XXXX-XXXX-XXXX",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("US_SSN_PATTERN", () => {
|
|
102
|
+
const { pattern, replacement } = US_SSN_PATTERN;
|
|
103
|
+
|
|
104
|
+
// Test valid US Social Security numbers
|
|
105
|
+
assertMatch("123-45-6789", pattern);
|
|
106
|
+
pattern.lastIndex = 0;
|
|
107
|
+
|
|
108
|
+
// Test replacements
|
|
109
|
+
assertEquals(
|
|
110
|
+
"SSN: 123-45-6789".replaceAll(pattern, replacement as string),
|
|
111
|
+
"SSN: XXX-XX-XXXX",
|
|
112
|
+
);
|
|
113
|
+
assertEquals(
|
|
114
|
+
"SSNs: 123-45-6789 and 987-65-4321".replaceAll(
|
|
115
|
+
pattern,
|
|
116
|
+
replacement as string,
|
|
117
|
+
),
|
|
118
|
+
"SSNs: XXX-XX-XXXX and XXX-XX-XXXX",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("KR_RRN_PATTERN", () => {
|
|
123
|
+
const { pattern, replacement } = KR_RRN_PATTERN;
|
|
124
|
+
|
|
125
|
+
// Test valid South Korean resident registration numbers
|
|
126
|
+
assertMatch("123456-7890123", pattern);
|
|
127
|
+
pattern.lastIndex = 0;
|
|
128
|
+
|
|
129
|
+
// Test replacements
|
|
130
|
+
assertEquals(
|
|
131
|
+
"RRN: 123456-7890123".replaceAll(pattern, replacement as string),
|
|
132
|
+
"RRN: XXXXXX-XXXXXXX",
|
|
133
|
+
);
|
|
134
|
+
assertEquals(
|
|
135
|
+
"RRNs: 123456-7890123 and 654321-0987654".replaceAll(
|
|
136
|
+
pattern,
|
|
137
|
+
replacement as string,
|
|
138
|
+
),
|
|
139
|
+
"RRNs: XXXXXX-XXXXXXX and XXXXXX-XXXXXXX",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("JWT_PATTERN", () => {
|
|
144
|
+
const { pattern, replacement } = JWT_PATTERN;
|
|
145
|
+
|
|
146
|
+
// Test valid JWT tokens
|
|
147
|
+
const sampleJwt =
|
|
148
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
|
149
|
+
assertMatch(sampleJwt, pattern);
|
|
150
|
+
pattern.lastIndex = 0;
|
|
151
|
+
|
|
152
|
+
// Test replacements
|
|
153
|
+
assertEquals(
|
|
154
|
+
`Token: ${sampleJwt}`.replaceAll(pattern, replacement as string),
|
|
155
|
+
"Token: [JWT REDACTED]",
|
|
156
|
+
);
|
|
157
|
+
assertEquals(
|
|
158
|
+
`First: ${sampleJwt}, Second: ${sampleJwt}`.replaceAll(
|
|
159
|
+
pattern,
|
|
160
|
+
replacement as string,
|
|
161
|
+
),
|
|
162
|
+
"First: [JWT REDACTED], Second: [JWT REDACTED]",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("redactByPattern(TextFormatter)", () => {
|
|
167
|
+
{ // redacts sensitive information in text output
|
|
168
|
+
// Create a simple TextFormatter that returns a string
|
|
169
|
+
const formatter: TextFormatter = (record: LogRecord) => {
|
|
170
|
+
return `[${record.level.toUpperCase()}] ${record.message.join(" ")}`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Test data with multiple patterns to redact
|
|
174
|
+
const record: LogRecord = {
|
|
175
|
+
level: "info",
|
|
176
|
+
category: ["test"],
|
|
177
|
+
message: [
|
|
178
|
+
"Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789",
|
|
179
|
+
],
|
|
180
|
+
rawMessage:
|
|
181
|
+
"Sensitive info: email = user@example.com, cc = 1234-5678-9012-3456, ssn = 123-45-6789",
|
|
182
|
+
timestamp: Date.now(),
|
|
183
|
+
properties: {},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Apply redaction with multiple patterns
|
|
187
|
+
const redactedFormatter = redactByPattern(formatter, [
|
|
188
|
+
EMAIL_ADDRESS_PATTERN,
|
|
189
|
+
CREDIT_CARD_NUMBER_PATTERN,
|
|
190
|
+
US_SSN_PATTERN,
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const output = redactedFormatter(record);
|
|
194
|
+
|
|
195
|
+
// Verify all sensitive data was redacted
|
|
196
|
+
assertEquals(
|
|
197
|
+
output,
|
|
198
|
+
"[INFO] Sensitive info: email = REDACTED@EMAIL.ADDRESS, cc = XXXX-XXXX-XXXX-XXXX, ssn = XXX-XX-XXXX",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
{ // handles function-based replacements
|
|
203
|
+
const formatter: TextFormatter = (record: LogRecord) => {
|
|
204
|
+
return record.message.join(" ");
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Custom pattern with function replacement
|
|
208
|
+
const customPattern: RedactionPattern = {
|
|
209
|
+
pattern: /\b(password|pw)=([^\s,]+)/g,
|
|
210
|
+
replacement: (_match, key) => `${key}=[HIDDEN]`,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const record: LogRecord = {
|
|
214
|
+
level: "info",
|
|
215
|
+
category: ["test"],
|
|
216
|
+
message: ["Credentials: password=secret123, pw=another-secret"],
|
|
217
|
+
rawMessage: "Credentials: password=secret123, pw=another-secret",
|
|
218
|
+
timestamp: Date.now(),
|
|
219
|
+
properties: {},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const redactedFormatter = redactByPattern(formatter, [customPattern]);
|
|
223
|
+
const output = redactedFormatter(record);
|
|
224
|
+
|
|
225
|
+
assertEquals(
|
|
226
|
+
output,
|
|
227
|
+
"Credentials: password=[HIDDEN], pw=[HIDDEN]",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
{ // throws error if global flag is not set
|
|
232
|
+
const formatter: TextFormatter = (record: LogRecord) =>
|
|
233
|
+
record.message.join(" ");
|
|
234
|
+
|
|
235
|
+
const invalidPattern: RedactionPattern = {
|
|
236
|
+
pattern: /password/, // Missing global flag
|
|
237
|
+
replacement: "****",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
assertThrows(
|
|
241
|
+
() => redactByPattern(formatter, [invalidPattern]),
|
|
242
|
+
TypeError,
|
|
243
|
+
"does not have the global flag set",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("redactByPattern(ConsoleFormatter)", () => {
|
|
249
|
+
{ // redacts sensitive information in console formatter arrays
|
|
250
|
+
// Create a simple ConsoleFormatter that returns an array of values
|
|
251
|
+
const formatter: ConsoleFormatter = (record: LogRecord) => {
|
|
252
|
+
return [
|
|
253
|
+
`[${record.level.toUpperCase()}]`,
|
|
254
|
+
...record.message,
|
|
255
|
+
];
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Create test record with sensitive data
|
|
259
|
+
const record: LogRecord = {
|
|
260
|
+
level: "info",
|
|
261
|
+
category: ["test"],
|
|
262
|
+
message: [
|
|
263
|
+
"User data:",
|
|
264
|
+
{
|
|
265
|
+
name: "John Doe",
|
|
266
|
+
email: "john@example.com",
|
|
267
|
+
creditCard: "1234-5678-9012-3456",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
rawMessage: "User data: [object Object]",
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
properties: {},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Apply redaction
|
|
276
|
+
const redactedFormatter = redactByPattern(formatter, [
|
|
277
|
+
EMAIL_ADDRESS_PATTERN,
|
|
278
|
+
CREDIT_CARD_NUMBER_PATTERN,
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const output = redactedFormatter(record);
|
|
282
|
+
|
|
283
|
+
// Verify output structure is preserved and data is redacted
|
|
284
|
+
assertEquals(output[0], "[INFO]");
|
|
285
|
+
assertEquals(output[1], "User data:");
|
|
286
|
+
assertEquals(
|
|
287
|
+
(output[2] as { name: string; email: string; creditCard: string }).name,
|
|
288
|
+
"John Doe",
|
|
289
|
+
);
|
|
290
|
+
assertEquals(
|
|
291
|
+
(output[2] as { name: string; email: string; creditCard: string })
|
|
292
|
+
.email,
|
|
293
|
+
"REDACTED@EMAIL.ADDRESS",
|
|
294
|
+
);
|
|
295
|
+
assertEquals(
|
|
296
|
+
(output[2] as { name: string; email: string; creditCard: string })
|
|
297
|
+
.creditCard,
|
|
298
|
+
"XXXX-XXXX-XXXX-XXXX",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
{ // handles nested objects and arrays in console output
|
|
303
|
+
const formatter: ConsoleFormatter = (record: LogRecord) => {
|
|
304
|
+
return [record.level, record.message];
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const nestedData = {
|
|
308
|
+
user: {
|
|
309
|
+
contact: {
|
|
310
|
+
email: "user@example.com",
|
|
311
|
+
phone: "123-456-7890",
|
|
312
|
+
},
|
|
313
|
+
payment: {
|
|
314
|
+
cards: [
|
|
315
|
+
"1234-5678-9012-3456",
|
|
316
|
+
"8765-4321-8765-4321",
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
documents: {
|
|
320
|
+
ssn: "123-45-6789",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const record: LogRecord = {
|
|
326
|
+
level: "info",
|
|
327
|
+
category: ["test"],
|
|
328
|
+
message: ["Data:", nestedData],
|
|
329
|
+
rawMessage: "Data: [object Object]",
|
|
330
|
+
timestamp: Date.now(),
|
|
331
|
+
properties: {},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const redactedFormatter = redactByPattern(formatter, [
|
|
335
|
+
EMAIL_ADDRESS_PATTERN,
|
|
336
|
+
CREDIT_CARD_NUMBER_PATTERN,
|
|
337
|
+
US_SSN_PATTERN,
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
const output = redactedFormatter(record);
|
|
341
|
+
|
|
342
|
+
// Verify deep redaction in nested structures
|
|
343
|
+
const resultData =
|
|
344
|
+
(output[1] as unknown[])[1] as unknown as typeof nestedData;
|
|
345
|
+
assertEquals(resultData.user.contact.email, "REDACTED@EMAIL.ADDRESS");
|
|
346
|
+
assertEquals(resultData.user.contact.phone, "123-456-7890"); // Not redacted
|
|
347
|
+
assertEquals(resultData.user.payment.cards[0], "XXXX-XXXX-XXXX-XXXX");
|
|
348
|
+
assertEquals(resultData.user.payment.cards[1], "XXXX-XXXX-XXXX-XXXX");
|
|
349
|
+
assertEquals(resultData.user.documents.ssn, "XXX-XX-XXXX");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
{ // handles circular references to prevent stack overflow
|
|
353
|
+
const formatter: ConsoleFormatter = (record: LogRecord) => [record.message];
|
|
354
|
+
|
|
355
|
+
const circularObj: Record<string, unknown> = {
|
|
356
|
+
email: "user@example.com",
|
|
357
|
+
};
|
|
358
|
+
circularObj.self = circularObj;
|
|
359
|
+
|
|
360
|
+
const record: LogRecord = {
|
|
361
|
+
level: "info",
|
|
362
|
+
category: ["test"],
|
|
363
|
+
message: ["Circular object:", circularObj],
|
|
364
|
+
rawMessage: "Circular object: [object Object]",
|
|
365
|
+
timestamp: Date.now(),
|
|
366
|
+
properties: {},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const redactedFormatter = redactByPattern(formatter, [
|
|
370
|
+
EMAIL_ADDRESS_PATTERN,
|
|
371
|
+
]);
|
|
372
|
+
const output = redactedFormatter(record);
|
|
373
|
+
|
|
374
|
+
const resultData = (output[0] as unknown[])[1] as Record<string, unknown>;
|
|
375
|
+
assertEquals(resultData.email, "REDACTED@EMAIL.ADDRESS");
|
|
376
|
+
assert(
|
|
377
|
+
resultData.self === resultData,
|
|
378
|
+
"Circular reference should be preserved",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
{ // redacts fields in class instances
|
|
383
|
+
const formatter: ConsoleFormatter = (record: LogRecord) => [record.message];
|
|
384
|
+
|
|
385
|
+
class User {
|
|
386
|
+
constructor(public email: string, public name: string) {}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const record: LogRecord = {
|
|
390
|
+
level: "info",
|
|
391
|
+
category: ["test"],
|
|
392
|
+
message: ["User object:", new User("user@example.com", "Alice")],
|
|
393
|
+
rawMessage: "User object: [object Object]",
|
|
394
|
+
timestamp: Date.now(),
|
|
395
|
+
properties: {},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const redactedFormatter = redactByPattern(formatter, [
|
|
399
|
+
EMAIL_ADDRESS_PATTERN,
|
|
400
|
+
]);
|
|
401
|
+
const output = redactedFormatter(record);
|
|
402
|
+
|
|
403
|
+
const resultUser = (output[0] as unknown[])[1] as User;
|
|
404
|
+
assertEquals(resultUser.email, "REDACTED@EMAIL.ADDRESS");
|
|
405
|
+
assertEquals(resultUser.name, "Alice");
|
|
406
|
+
}
|
|
407
|
+
});
|
package/src/pattern.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleFormatter,
|
|
3
|
+
LogRecord,
|
|
4
|
+
TextFormatter,
|
|
5
|
+
} from "@logtape/logtape";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A redaction pattern, which is a pair of regular expression and replacement
|
|
9
|
+
* string or function.
|
|
10
|
+
* @since 0.10.0
|
|
11
|
+
*/
|
|
12
|
+
export interface RedactionPattern {
|
|
13
|
+
/**
|
|
14
|
+
* The regular expression to match against. Note that it must have the
|
|
15
|
+
* `g` (global) flag set, otherwise it will throw a `TypeError`.
|
|
16
|
+
*/
|
|
17
|
+
readonly pattern: RegExp;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The replacement string or function. If the replacement is a function,
|
|
21
|
+
* it will be called with the matched string and any capture groups (the same
|
|
22
|
+
* signature as `String.prototype.replaceAll()`).
|
|
23
|
+
*/
|
|
24
|
+
readonly replacement:
|
|
25
|
+
| string
|
|
26
|
+
// deno-lint-ignore no-explicit-any
|
|
27
|
+
| ((match: string, ...rest: readonly any[]) => string);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A redaction pattern for email addresses.
|
|
32
|
+
* @since 0.10.0
|
|
33
|
+
*/
|
|
34
|
+
export const EMAIL_ADDRESS_PATTERN: RedactionPattern = {
|
|
35
|
+
pattern:
|
|
36
|
+
/[\p{L}0-9.!#$%&'*+/=?^_`{|}~-]+@[\p{L}0-9](?:[\p{L}0-9-]{0,61}[\p{L}0-9])?(?:\.[\p{L}0-9](?:[\p{L}0-9-]{0,61}[\p{L}0-9])?)+/gu,
|
|
37
|
+
replacement: "REDACTED@EMAIL.ADDRESS",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A redaction pattern for credit card numbers (including American Express).
|
|
42
|
+
* @since 0.10.0
|
|
43
|
+
*/
|
|
44
|
+
export const CREDIT_CARD_NUMBER_PATTERN: RedactionPattern = {
|
|
45
|
+
pattern: /(?:\d{4}-){3}\d{4}|(?:\d{4}-){2}\d{6}/g,
|
|
46
|
+
replacement: "XXXX-XXXX-XXXX-XXXX",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A redaction pattern for U.S. Social Security numbers.
|
|
51
|
+
* @since 0.10.0
|
|
52
|
+
*/
|
|
53
|
+
export const US_SSN_PATTERN: RedactionPattern = {
|
|
54
|
+
pattern: /\d{3}-\d{2}-\d{4}/g,
|
|
55
|
+
replacement: "XXX-XX-XXXX",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A redaction pattern for South Korean resident registration numbers
|
|
60
|
+
* (住民登錄番號).
|
|
61
|
+
* @since 0.10.0
|
|
62
|
+
*/
|
|
63
|
+
export const KR_RRN_PATTERN: RedactionPattern = {
|
|
64
|
+
pattern: /\d{6}-\d{7}/g,
|
|
65
|
+
replacement: "XXXXXX-XXXXXXX",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A redaction pattern for JSON Web Tokens (JWT).
|
|
70
|
+
* @since 0.10.0
|
|
71
|
+
*/
|
|
72
|
+
export const JWT_PATTERN: RedactionPattern = {
|
|
73
|
+
pattern: /eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g,
|
|
74
|
+
replacement: "[JWT REDACTED]",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A list of {@link RedactionPattern}s.
|
|
79
|
+
* @since 0.10.0
|
|
80
|
+
*/
|
|
81
|
+
export type RedactionPatterns = readonly RedactionPattern[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Applies data redaction to a {@link TextFormatter}.
|
|
85
|
+
*
|
|
86
|
+
* Note that there are some built-in redaction patterns:
|
|
87
|
+
*
|
|
88
|
+
* - {@link CREDIT_CARD_NUMBER_PATTERN}
|
|
89
|
+
* - {@link EMAIL_ADDRESS_PATTERN}
|
|
90
|
+
* - {@link JWT_PATTERN}
|
|
91
|
+
* - {@link KR_RRN_PATTERN}
|
|
92
|
+
* - {@link US_SSN_PATTERN}
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* import { getFileSink } from "@logtape/file";
|
|
97
|
+
* import { getAnsiColorFormatter } from "@logtape/logtape";
|
|
98
|
+
* import {
|
|
99
|
+
* CREDIT_CARD_NUMBER_PATTERN,
|
|
100
|
+
* EMAIL_ADDRESS_PATTERN,
|
|
101
|
+
* JWT_PATTERN,
|
|
102
|
+
* redactByPattern,
|
|
103
|
+
* } from "@logtape/redaction";
|
|
104
|
+
*
|
|
105
|
+
* const formatter = redactByPattern(getAnsiConsoleFormatter(), [
|
|
106
|
+
* CREDIT_CARD_NUMBER_PATTERN,
|
|
107
|
+
* EMAIL_ADDRESS_PATTERN,
|
|
108
|
+
* JWT_PATTERN,
|
|
109
|
+
* ]);
|
|
110
|
+
* const sink = getFileSink("my-app.log", { formatter });
|
|
111
|
+
* ```
|
|
112
|
+
* @param formatter The text formatter to apply redaction to.
|
|
113
|
+
* @param patterns The redaction patterns to apply.
|
|
114
|
+
* @returns The redacted text formatter.
|
|
115
|
+
* @since 0.10.0
|
|
116
|
+
*/
|
|
117
|
+
export function redactByPattern(
|
|
118
|
+
formatter: TextFormatter,
|
|
119
|
+
patterns: RedactionPatterns,
|
|
120
|
+
): TextFormatter;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Applies data redaction to a {@link ConsoleFormatter}.
|
|
124
|
+
*
|
|
125
|
+
* Note that there are some built-in redaction patterns:
|
|
126
|
+
*
|
|
127
|
+
* - {@link CREDIT_CARD_NUMBER_PATTERN}
|
|
128
|
+
* - {@link EMAIL_ADDRESS_PATTERN}
|
|
129
|
+
* - {@link JWT_PATTERN}
|
|
130
|
+
* - {@link KR_RRN_PATTERN}
|
|
131
|
+
* - {@link US_SSN_PATTERN}
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* import { defaultConsoleFormatter, getConsoleSink } from "@logtape/logtape";
|
|
136
|
+
* import {
|
|
137
|
+
* CREDIT_CARD_NUMBER_PATTERN,
|
|
138
|
+
* EMAIL_ADDRESS_PATTERN,
|
|
139
|
+
* JWT_PATTERN,
|
|
140
|
+
* redactByPattern,
|
|
141
|
+
* } from "@logtape/redaction";
|
|
142
|
+
*
|
|
143
|
+
* const formatter = redactByPattern(defaultConsoleFormatter, [
|
|
144
|
+
* CREDIT_CARD_NUMBER_PATTERN,
|
|
145
|
+
* EMAIL_ADDRESS_PATTERN,
|
|
146
|
+
* JWT_PATTERN,
|
|
147
|
+
* ]);
|
|
148
|
+
* const sink = getConsoleSink({ formatter });
|
|
149
|
+
* ```
|
|
150
|
+
* @param formatter The console formatter to apply redaction to.
|
|
151
|
+
* @param patterns The redaction patterns to apply.
|
|
152
|
+
* @returns The redacted console formatter.
|
|
153
|
+
* @since 0.10.0
|
|
154
|
+
*/
|
|
155
|
+
export function redactByPattern(
|
|
156
|
+
formatter: ConsoleFormatter,
|
|
157
|
+
patterns: RedactionPatterns,
|
|
158
|
+
): ConsoleFormatter;
|
|
159
|
+
|
|
160
|
+
export function redactByPattern(
|
|
161
|
+
formatter: TextFormatter | ConsoleFormatter,
|
|
162
|
+
patterns: RedactionPatterns,
|
|
163
|
+
): (record: LogRecord) => string | readonly unknown[] {
|
|
164
|
+
for (const { pattern } of patterns) {
|
|
165
|
+
if (!pattern.global) {
|
|
166
|
+
throw new TypeError(
|
|
167
|
+
`Pattern ${pattern} does not have the global flag set.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function replaceString(str: string): string {
|
|
173
|
+
for (const p of patterns) {
|
|
174
|
+
// The following ternary operator may seem strange, but it's for
|
|
175
|
+
// making TypeScript happy:
|
|
176
|
+
str = typeof p.replacement === "string"
|
|
177
|
+
? str.replaceAll(p.pattern, p.replacement)
|
|
178
|
+
: str.replaceAll(p.pattern, p.replacement);
|
|
179
|
+
}
|
|
180
|
+
return str;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function replaceObject(
|
|
184
|
+
object: unknown,
|
|
185
|
+
visited: Map<object, object>,
|
|
186
|
+
): unknown {
|
|
187
|
+
if (typeof object === "object" && object !== null) {
|
|
188
|
+
if (visited.has(object)) {
|
|
189
|
+
return visited.get(object)!; // Circular reference detected
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (typeof object === "string") return replaceString(object);
|
|
194
|
+
if (Array.isArray(object)) {
|
|
195
|
+
const copy: unknown[] = [];
|
|
196
|
+
visited.set(object, copy);
|
|
197
|
+
object.forEach((item) => copy.push(replaceObject(item, visited)));
|
|
198
|
+
return copy;
|
|
199
|
+
}
|
|
200
|
+
if (typeof object === "object" && object !== null) {
|
|
201
|
+
const redacted: Record<string, unknown> = {};
|
|
202
|
+
visited.set(object, redacted);
|
|
203
|
+
for (const key in object) {
|
|
204
|
+
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
|
205
|
+
redacted[key] = replaceObject(
|
|
206
|
+
(object as Record<string, unknown>)[key],
|
|
207
|
+
visited,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return redacted;
|
|
212
|
+
}
|
|
213
|
+
return object;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (record: LogRecord) => {
|
|
217
|
+
const output = formatter(record);
|
|
218
|
+
if (typeof output === "string") return replaceString(output);
|
|
219
|
+
return output.map((obj) => replaceObject(obj, new Map()));
|
|
220
|
+
};
|
|
221
|
+
}
|