@logtape/redaction 1.2.0 → 1.2.1
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 +1 -1
- package/dist/field.cjs +144 -4
- package/dist/field.d.cts +9 -3
- package/dist/field.d.cts.map +1 -1
- package/dist/field.d.ts +9 -3
- package/dist/field.d.ts.map +1 -1
- package/dist/field.js +144 -4
- package/dist/field.js.map +1 -1
- package/package.json +2 -2
- package/src/field.test.ts +200 -0
- package/src/field.ts +239 -4
package/deno.json
CHANGED
package/dist/field.cjs
CHANGED
|
@@ -22,11 +22,17 @@ const DEFAULT_REDACT_FIELDS = [
|
|
|
22
22
|
/address/i
|
|
23
23
|
];
|
|
24
24
|
/**
|
|
25
|
-
* Redacts properties in a {@link LogRecord} based on the
|
|
26
|
-
* patterns and action.
|
|
25
|
+
* Redacts properties and message values in a {@link LogRecord} based on the
|
|
26
|
+
* provided field patterns and action.
|
|
27
27
|
*
|
|
28
28
|
* Note that it is a decorator which wraps the sink and redacts properties
|
|
29
|
-
* before passing them to the sink.
|
|
29
|
+
* and message values before passing them to the sink.
|
|
30
|
+
*
|
|
31
|
+
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
|
|
32
|
+
* matched against the field patterns to determine which values to redact.
|
|
33
|
+
*
|
|
34
|
+
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
|
|
35
|
+
* is performed by comparing message values with redacted property values.
|
|
30
36
|
*
|
|
31
37
|
* @example
|
|
32
38
|
* ```ts
|
|
@@ -44,9 +50,20 @@ const DEFAULT_REDACT_FIELDS = [
|
|
|
44
50
|
function redactByField(sink, options = DEFAULT_REDACT_FIELDS) {
|
|
45
51
|
const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
|
|
46
52
|
const wrapped = (record) => {
|
|
53
|
+
const redactedProperties = redactProperties(record.properties, opts);
|
|
54
|
+
let redactedMessage = record.message;
|
|
55
|
+
if (typeof record.rawMessage === "string") {
|
|
56
|
+
const placeholders = extractPlaceholderNames(record.rawMessage);
|
|
57
|
+
const redactedIndices = getRedactedPlaceholderIndices(placeholders, opts.fieldPatterns);
|
|
58
|
+
if (redactedIndices.size > 0) redactedMessage = redactMessageArray(record.message, redactedIndices, opts.action);
|
|
59
|
+
} else {
|
|
60
|
+
const redactedValues = getRedactedValues(record.properties, redactedProperties);
|
|
61
|
+
if (redactedValues.size > 0) redactedMessage = redactMessageByValues(record.message, redactedValues);
|
|
62
|
+
}
|
|
47
63
|
sink({
|
|
48
64
|
...record,
|
|
49
|
-
|
|
65
|
+
message: redactedMessage,
|
|
66
|
+
properties: redactedProperties
|
|
50
67
|
});
|
|
51
68
|
};
|
|
52
69
|
if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];
|
|
@@ -99,6 +116,129 @@ function shouldFieldRedacted(field, fieldPatterns) {
|
|
|
99
116
|
} else if (fieldPattern.test(field)) return true;
|
|
100
117
|
return false;
|
|
101
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Extracts placeholder names from a message template string in order.
|
|
121
|
+
* @param template The message template string.
|
|
122
|
+
* @returns An array of placeholder names in the order they appear.
|
|
123
|
+
*/
|
|
124
|
+
function extractPlaceholderNames(template) {
|
|
125
|
+
const placeholders = [];
|
|
126
|
+
for (let i = 0; i < template.length; i++) if (template[i] === "{") {
|
|
127
|
+
if (i + 1 < template.length && template[i + 1] === "{") {
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const closeIndex = template.indexOf("}", i + 1);
|
|
132
|
+
if (closeIndex === -1) continue;
|
|
133
|
+
const key = template.slice(i + 1, closeIndex).trim();
|
|
134
|
+
placeholders.push(key);
|
|
135
|
+
i = closeIndex;
|
|
136
|
+
}
|
|
137
|
+
return placeholders;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Parses a property path into its segments.
|
|
141
|
+
* @param path The property path (e.g., "user.password" or "users[0].email").
|
|
142
|
+
* @returns An array of path segments.
|
|
143
|
+
*/
|
|
144
|
+
function parsePathSegments(path) {
|
|
145
|
+
const segments = [];
|
|
146
|
+
let current = "";
|
|
147
|
+
for (const char of path) if (char === "." || char === "[") {
|
|
148
|
+
if (current) segments.push(current);
|
|
149
|
+
current = "";
|
|
150
|
+
} else if (char === "]" || char === "?") {} else current += char;
|
|
151
|
+
if (current) segments.push(current);
|
|
152
|
+
return segments;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Determines which placeholder indices should be redacted based on field
|
|
156
|
+
* patterns.
|
|
157
|
+
* @param placeholders Array of placeholder names from the template.
|
|
158
|
+
* @param fieldPatterns Field patterns to match against.
|
|
159
|
+
* @returns Set of indices that should be redacted.
|
|
160
|
+
*/
|
|
161
|
+
function getRedactedPlaceholderIndices(placeholders, fieldPatterns) {
|
|
162
|
+
const indices = /* @__PURE__ */ new Set();
|
|
163
|
+
for (let i = 0; i < placeholders.length; i++) {
|
|
164
|
+
const placeholder = placeholders[i];
|
|
165
|
+
if (placeholder === "*") continue;
|
|
166
|
+
if (shouldFieldRedacted(placeholder, fieldPatterns)) {
|
|
167
|
+
indices.add(i);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const segments = parsePathSegments(placeholder);
|
|
171
|
+
for (const segment of segments) if (shouldFieldRedacted(segment, fieldPatterns)) {
|
|
172
|
+
indices.add(i);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return indices;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Redacts values in the message array based on the redacted placeholder
|
|
180
|
+
* indices.
|
|
181
|
+
* @param message The original message array.
|
|
182
|
+
* @param redactedIndices Set of placeholder indices to redact.
|
|
183
|
+
* @param action The redaction action.
|
|
184
|
+
* @returns New message array with redacted values.
|
|
185
|
+
*/
|
|
186
|
+
function redactMessageArray(message, redactedIndices, action) {
|
|
187
|
+
if (redactedIndices.size === 0) return message;
|
|
188
|
+
const result = [];
|
|
189
|
+
let placeholderIndex = 0;
|
|
190
|
+
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
|
|
191
|
+
else {
|
|
192
|
+
if (redactedIndices.has(placeholderIndex)) if (action == null || action === "delete") result.push("");
|
|
193
|
+
else result.push(action(message[i]));
|
|
194
|
+
else result.push(message[i]);
|
|
195
|
+
placeholderIndex++;
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Collects redacted value mappings from original to redacted properties.
|
|
201
|
+
* @param original The original properties.
|
|
202
|
+
* @param redacted The redacted properties.
|
|
203
|
+
* @param map The map to populate with original -> redacted value pairs.
|
|
204
|
+
*/
|
|
205
|
+
function collectRedactedValues(original, redacted, map) {
|
|
206
|
+
for (const key in original) {
|
|
207
|
+
const origVal = original[key];
|
|
208
|
+
const redVal = redacted[key];
|
|
209
|
+
if (origVal !== redVal) map.set(origVal, redVal);
|
|
210
|
+
if (typeof origVal === "object" && origVal !== null && typeof redVal === "object" && redVal !== null && !Array.isArray(origVal)) collectRedactedValues(origVal, redVal, map);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Gets a map of original values to their redacted replacements.
|
|
215
|
+
* @param original The original properties.
|
|
216
|
+
* @param redacted The redacted properties.
|
|
217
|
+
* @returns A map of original -> redacted values.
|
|
218
|
+
*/
|
|
219
|
+
function getRedactedValues(original, redacted) {
|
|
220
|
+
const map = /* @__PURE__ */ new Map();
|
|
221
|
+
collectRedactedValues(original, redacted, map);
|
|
222
|
+
return map;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Redacts message array values by comparing with redacted property values.
|
|
226
|
+
* Used for tagged template literals where placeholder names are not available.
|
|
227
|
+
* @param message The original message array.
|
|
228
|
+
* @param redactedValues Map of original -> redacted values.
|
|
229
|
+
* @returns New message array with redacted values.
|
|
230
|
+
*/
|
|
231
|
+
function redactMessageByValues(message, redactedValues) {
|
|
232
|
+
if (redactedValues.size === 0) return message;
|
|
233
|
+
const result = [];
|
|
234
|
+
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
|
|
235
|
+
else {
|
|
236
|
+
const val = message[i];
|
|
237
|
+
if (redactedValues.has(val)) result.push(redactedValues.get(val));
|
|
238
|
+
else result.push(val);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
102
242
|
|
|
103
243
|
//#endregion
|
|
104
244
|
exports.DEFAULT_REDACT_FIELDS = DEFAULT_REDACT_FIELDS;
|
package/dist/field.d.cts
CHANGED
|
@@ -47,11 +47,17 @@ interface FieldRedactionOptions {
|
|
|
47
47
|
readonly action?: "delete" | ((value: unknown) => unknown);
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Redacts properties in a {@link LogRecord} based on the
|
|
51
|
-
* patterns and action.
|
|
50
|
+
* Redacts properties and message values in a {@link LogRecord} based on the
|
|
51
|
+
* provided field patterns and action.
|
|
52
52
|
*
|
|
53
53
|
* Note that it is a decorator which wraps the sink and redacts properties
|
|
54
|
-
* before passing them to the sink.
|
|
54
|
+
* and message values before passing them to the sink.
|
|
55
|
+
*
|
|
56
|
+
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
|
|
57
|
+
* matched against the field patterns to determine which values to redact.
|
|
58
|
+
*
|
|
59
|
+
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
|
|
60
|
+
* is performed by comparing message values with redacted property values.
|
|
55
61
|
*
|
|
56
62
|
* @example
|
|
57
63
|
* ```ts
|
package/dist/field.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field.d.cts","names":[],"sources":["../src/field.ts"],"sourcesContent":[],"mappings":";;;;;;AAOA;AAOA;AAQA;AAqBiB,KApCL,YAAA,GAoCK,MAAqB,GApCF,MA2CV;
|
|
1
|
+
{"version":3,"file":"field.d.cts","names":[],"sources":["../src/field.ts"],"sourcesContent":[],"mappings":";;;;;;AAOA;AAOA;AAQA;AAqBiB,KApCL,YAAA,GAoCK,MAAqB,GApCF,MA2CV;AAyC1B;;;;;AACmC,KA9EvB,aAAA,GAAgB,YA8EO,EAAA;;;;;;;AAEL,cAxEjB,qBAwEiB,EAxEM,aAwEN;;AAAsB;;;;UAnDnC,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyCV,aAAA,OACR,OAAO,OAAO,aAAa,OAAO,2BAC/B,wBAAwB,gBAChC,OAAO,OAAO,aAAa,OAAO"}
|
package/dist/field.d.ts
CHANGED
|
@@ -47,11 +47,17 @@ interface FieldRedactionOptions {
|
|
|
47
47
|
readonly action?: "delete" | ((value: unknown) => unknown);
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Redacts properties in a {@link LogRecord} based on the
|
|
51
|
-
* patterns and action.
|
|
50
|
+
* Redacts properties and message values in a {@link LogRecord} based on the
|
|
51
|
+
* provided field patterns and action.
|
|
52
52
|
*
|
|
53
53
|
* Note that it is a decorator which wraps the sink and redacts properties
|
|
54
|
-
* before passing them to the sink.
|
|
54
|
+
* and message values before passing them to the sink.
|
|
55
|
+
*
|
|
56
|
+
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
|
|
57
|
+
* matched against the field patterns to determine which values to redact.
|
|
58
|
+
*
|
|
59
|
+
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
|
|
60
|
+
* is performed by comparing message values with redacted property values.
|
|
55
61
|
*
|
|
56
62
|
* @example
|
|
57
63
|
* ```ts
|
package/dist/field.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field.d.ts","names":[],"sources":["../src/field.ts"],"sourcesContent":[],"mappings":";;;;;;AAOA;AAOA;AAQA;AAqBiB,KApCL,YAAA,GAoCK,MAAqB,GApCF,MA2CV;
|
|
1
|
+
{"version":3,"file":"field.d.ts","names":[],"sources":["../src/field.ts"],"sourcesContent":[],"mappings":";;;;;;AAOA;AAOA;AAQA;AAqBiB,KApCL,YAAA,GAoCK,MAAqB,GApCF,MA2CV;AAyC1B;;;;;AACmC,KA9EvB,aAAA,GAAgB,YA8EO,EAAA;;;;;;;AAEL,cAxEjB,qBAwEiB,EAxEM,aAwEN;;AAAsB;;;;UAnDnC,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyCV,aAAA,OACR,OAAO,OAAO,aAAa,OAAO,2BAC/B,wBAAwB,gBAChC,OAAO,OAAO,aAAa,OAAO"}
|
package/dist/field.js
CHANGED
|
@@ -21,11 +21,17 @@ const DEFAULT_REDACT_FIELDS = [
|
|
|
21
21
|
/address/i
|
|
22
22
|
];
|
|
23
23
|
/**
|
|
24
|
-
* Redacts properties in a {@link LogRecord} based on the
|
|
25
|
-
* patterns and action.
|
|
24
|
+
* Redacts properties and message values in a {@link LogRecord} based on the
|
|
25
|
+
* provided field patterns and action.
|
|
26
26
|
*
|
|
27
27
|
* Note that it is a decorator which wraps the sink and redacts properties
|
|
28
|
-
* before passing them to the sink.
|
|
28
|
+
* and message values before passing them to the sink.
|
|
29
|
+
*
|
|
30
|
+
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
|
|
31
|
+
* matched against the field patterns to determine which values to redact.
|
|
32
|
+
*
|
|
33
|
+
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
|
|
34
|
+
* is performed by comparing message values with redacted property values.
|
|
29
35
|
*
|
|
30
36
|
* @example
|
|
31
37
|
* ```ts
|
|
@@ -43,9 +49,20 @@ const DEFAULT_REDACT_FIELDS = [
|
|
|
43
49
|
function redactByField(sink, options = DEFAULT_REDACT_FIELDS) {
|
|
44
50
|
const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
|
|
45
51
|
const wrapped = (record) => {
|
|
52
|
+
const redactedProperties = redactProperties(record.properties, opts);
|
|
53
|
+
let redactedMessage = record.message;
|
|
54
|
+
if (typeof record.rawMessage === "string") {
|
|
55
|
+
const placeholders = extractPlaceholderNames(record.rawMessage);
|
|
56
|
+
const redactedIndices = getRedactedPlaceholderIndices(placeholders, opts.fieldPatterns);
|
|
57
|
+
if (redactedIndices.size > 0) redactedMessage = redactMessageArray(record.message, redactedIndices, opts.action);
|
|
58
|
+
} else {
|
|
59
|
+
const redactedValues = getRedactedValues(record.properties, redactedProperties);
|
|
60
|
+
if (redactedValues.size > 0) redactedMessage = redactMessageByValues(record.message, redactedValues);
|
|
61
|
+
}
|
|
46
62
|
sink({
|
|
47
63
|
...record,
|
|
48
|
-
|
|
64
|
+
message: redactedMessage,
|
|
65
|
+
properties: redactedProperties
|
|
49
66
|
});
|
|
50
67
|
};
|
|
51
68
|
if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];
|
|
@@ -98,6 +115,129 @@ function shouldFieldRedacted(field, fieldPatterns) {
|
|
|
98
115
|
} else if (fieldPattern.test(field)) return true;
|
|
99
116
|
return false;
|
|
100
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Extracts placeholder names from a message template string in order.
|
|
120
|
+
* @param template The message template string.
|
|
121
|
+
* @returns An array of placeholder names in the order they appear.
|
|
122
|
+
*/
|
|
123
|
+
function extractPlaceholderNames(template) {
|
|
124
|
+
const placeholders = [];
|
|
125
|
+
for (let i = 0; i < template.length; i++) if (template[i] === "{") {
|
|
126
|
+
if (i + 1 < template.length && template[i + 1] === "{") {
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const closeIndex = template.indexOf("}", i + 1);
|
|
131
|
+
if (closeIndex === -1) continue;
|
|
132
|
+
const key = template.slice(i + 1, closeIndex).trim();
|
|
133
|
+
placeholders.push(key);
|
|
134
|
+
i = closeIndex;
|
|
135
|
+
}
|
|
136
|
+
return placeholders;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Parses a property path into its segments.
|
|
140
|
+
* @param path The property path (e.g., "user.password" or "users[0].email").
|
|
141
|
+
* @returns An array of path segments.
|
|
142
|
+
*/
|
|
143
|
+
function parsePathSegments(path) {
|
|
144
|
+
const segments = [];
|
|
145
|
+
let current = "";
|
|
146
|
+
for (const char of path) if (char === "." || char === "[") {
|
|
147
|
+
if (current) segments.push(current);
|
|
148
|
+
current = "";
|
|
149
|
+
} else if (char === "]" || char === "?") {} else current += char;
|
|
150
|
+
if (current) segments.push(current);
|
|
151
|
+
return segments;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Determines which placeholder indices should be redacted based on field
|
|
155
|
+
* patterns.
|
|
156
|
+
* @param placeholders Array of placeholder names from the template.
|
|
157
|
+
* @param fieldPatterns Field patterns to match against.
|
|
158
|
+
* @returns Set of indices that should be redacted.
|
|
159
|
+
*/
|
|
160
|
+
function getRedactedPlaceholderIndices(placeholders, fieldPatterns) {
|
|
161
|
+
const indices = /* @__PURE__ */ new Set();
|
|
162
|
+
for (let i = 0; i < placeholders.length; i++) {
|
|
163
|
+
const placeholder = placeholders[i];
|
|
164
|
+
if (placeholder === "*") continue;
|
|
165
|
+
if (shouldFieldRedacted(placeholder, fieldPatterns)) {
|
|
166
|
+
indices.add(i);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const segments = parsePathSegments(placeholder);
|
|
170
|
+
for (const segment of segments) if (shouldFieldRedacted(segment, fieldPatterns)) {
|
|
171
|
+
indices.add(i);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return indices;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Redacts values in the message array based on the redacted placeholder
|
|
179
|
+
* indices.
|
|
180
|
+
* @param message The original message array.
|
|
181
|
+
* @param redactedIndices Set of placeholder indices to redact.
|
|
182
|
+
* @param action The redaction action.
|
|
183
|
+
* @returns New message array with redacted values.
|
|
184
|
+
*/
|
|
185
|
+
function redactMessageArray(message, redactedIndices, action) {
|
|
186
|
+
if (redactedIndices.size === 0) return message;
|
|
187
|
+
const result = [];
|
|
188
|
+
let placeholderIndex = 0;
|
|
189
|
+
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
|
|
190
|
+
else {
|
|
191
|
+
if (redactedIndices.has(placeholderIndex)) if (action == null || action === "delete") result.push("");
|
|
192
|
+
else result.push(action(message[i]));
|
|
193
|
+
else result.push(message[i]);
|
|
194
|
+
placeholderIndex++;
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Collects redacted value mappings from original to redacted properties.
|
|
200
|
+
* @param original The original properties.
|
|
201
|
+
* @param redacted The redacted properties.
|
|
202
|
+
* @param map The map to populate with original -> redacted value pairs.
|
|
203
|
+
*/
|
|
204
|
+
function collectRedactedValues(original, redacted, map) {
|
|
205
|
+
for (const key in original) {
|
|
206
|
+
const origVal = original[key];
|
|
207
|
+
const redVal = redacted[key];
|
|
208
|
+
if (origVal !== redVal) map.set(origVal, redVal);
|
|
209
|
+
if (typeof origVal === "object" && origVal !== null && typeof redVal === "object" && redVal !== null && !Array.isArray(origVal)) collectRedactedValues(origVal, redVal, map);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Gets a map of original values to their redacted replacements.
|
|
214
|
+
* @param original The original properties.
|
|
215
|
+
* @param redacted The redacted properties.
|
|
216
|
+
* @returns A map of original -> redacted values.
|
|
217
|
+
*/
|
|
218
|
+
function getRedactedValues(original, redacted) {
|
|
219
|
+
const map = /* @__PURE__ */ new Map();
|
|
220
|
+
collectRedactedValues(original, redacted, map);
|
|
221
|
+
return map;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Redacts message array values by comparing with redacted property values.
|
|
225
|
+
* Used for tagged template literals where placeholder names are not available.
|
|
226
|
+
* @param message The original message array.
|
|
227
|
+
* @param redactedValues Map of original -> redacted values.
|
|
228
|
+
* @returns New message array with redacted values.
|
|
229
|
+
*/
|
|
230
|
+
function redactMessageByValues(message, redactedValues) {
|
|
231
|
+
if (redactedValues.size === 0) return message;
|
|
232
|
+
const result = [];
|
|
233
|
+
for (let i = 0; i < message.length; i++) if (i % 2 === 0) result.push(message[i]);
|
|
234
|
+
else {
|
|
235
|
+
const val = message[i];
|
|
236
|
+
if (redactedValues.has(val)) result.push(redactedValues.get(val));
|
|
237
|
+
else result.push(val);
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
101
241
|
|
|
102
242
|
//#endregion
|
|
103
243
|
export { DEFAULT_REDACT_FIELDS, redactByField };
|
package/dist/field.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field.js","names":["DEFAULT_REDACT_FIELDS: FieldPatterns","sink: Sink | Sink & Disposable | Sink & AsyncDisposable","options: FieldRedactionOptions | FieldPatterns","record: LogRecord","properties: Record<string, unknown>","options: FieldRedactionOptions","field: string","fieldPatterns: FieldPatterns"],"sources":["../src/field.ts"],"sourcesContent":["import type { LogRecord, Sink } from \"@logtape/logtape\";\n\n/**\n * The type for a field pattern used in redaction. A string or a regular\n * expression that matches field names.\n * @since 0.10.0\n */\nexport type FieldPattern = string | RegExp;\n\n/**\n * An array of field patterns used for redaction. Each pattern can be\n * a string or a regular expression that matches field names.\n * @since 0.10.0\n */\nexport type FieldPatterns = FieldPattern[];\n\n/**\n * Default field patterns for redaction. These patterns will match\n * common sensitive fields such as passwords, tokens, and personal\n * information.\n * @since 0.10.0\n */\nexport const DEFAULT_REDACT_FIELDS: FieldPatterns = [\n /pass(?:code|phrase|word)/i,\n /secret/i,\n /token/i,\n /key/i,\n /credential/i,\n /auth/i,\n /signature/i,\n /sensitive/i,\n /private/i,\n /ssn/i,\n /email/i,\n /phone/i,\n /address/i,\n];\n\n/**\n * Options for redacting fields in a {@link LogRecord}. Used by\n * the {@link redactByField} function.\n * @since 0.10.0\n */\nexport interface FieldRedactionOptions {\n /**\n * The field patterns to match against. This can be an array of\n * strings or regular expressions. If a field matches any of the\n * patterns, it will be redacted.\n * @defaultValue {@link DEFAULT_REDACT_FIELDS}\n */\n readonly fieldPatterns: FieldPatterns;\n\n /**\n * The action to perform on the matched fields. If not provided,\n * the default action is to delete the field from the properties.\n * If a function is provided, it will be called with the\n * value of the field, and the return value will be used to replace\n * the field in the properties.\n * If the action is `\"delete\"`, the field will be removed from the\n * properties.\n * @default `\"delete\"`\n */\n readonly action?: \"delete\" | ((value: unknown) => unknown);\n}\n\n/**\n * Redacts properties in a {@link LogRecord} based on the provided field\n * patterns and action.\n *\n * Note that it is a decorator which wraps the sink and redacts properties\n * before passing them to the sink.\n *\n * @example\n * ```ts\n * import { getConsoleSink } from \"@logtape/logtape\";\n * import { redactByField } from \"@logtape/redaction\";\n *\n * const sink = redactByField(getConsoleSink());\n * ```\n *\n * @param sink The sink to wrap.\n * @param options The redaction options.\n * @returns The wrapped sink.\n * @since 0.10.0\n */\nexport function redactByField(\n sink: Sink | Sink & Disposable | Sink & AsyncDisposable,\n options: FieldRedactionOptions | FieldPatterns = DEFAULT_REDACT_FIELDS,\n): Sink | Sink & Disposable | Sink & AsyncDisposable {\n const opts = Array.isArray(options) ? { fieldPatterns: options } : options;\n const wrapped = (record: LogRecord) => {\n sink({ ...record, properties: redactProperties(record.properties, opts) });\n };\n if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];\n if (Symbol.asyncDispose in sink) {\n wrapped[Symbol.asyncDispose] = sink[Symbol.asyncDispose];\n }\n return wrapped;\n}\n\n/**\n * Redacts properties from an object based on specified field patterns.\n *\n * This function creates a shallow copy of the input object and applies\n * redaction rules to its properties. For properties that match the redaction\n * patterns, the function either removes them or transforms their values based\n * on the provided action.\n *\n * The redaction process is recursive and will be applied to nested objects\n * as well, allowing for deep redaction of sensitive data in complex object\n * structures.\n * @param properties The properties to redact.\n * @param options The redaction options.\n * @returns The redacted properties.\n * @since 0.10.0\n */\nexport function redactProperties(\n properties: Record<string, unknown>,\n options: FieldRedactionOptions,\n): Record<string, unknown> {\n const copy = { ...properties };\n for (const field in copy) {\n if (shouldFieldRedacted(field, options.fieldPatterns)) {\n if (options.action == null || options.action === \"delete\") {\n delete copy[field];\n } else {\n copy[field] = options.action(copy[field]);\n }\n continue;\n }\n const value = copy[field];\n // Check if value is an array:\n if (Array.isArray(value)) {\n copy[field] = value.map((item) => {\n if (\n typeof item === \"object\" && item !== null &&\n (Object.getPrototypeOf(item) === Object.prototype ||\n Object.getPrototypeOf(item) === null)\n ) {\n // @ts-ignore: item is always Record<string, unknown>\n return redactProperties(item, options);\n }\n return item;\n });\n // Check if value is a vanilla object:\n } else if (\n typeof value === \"object\" && value !== null &&\n (Object.getPrototypeOf(value) === Object.prototype ||\n Object.getPrototypeOf(value) === null)\n ) {\n // @ts-ignore: value is always Record<string, unknown>\n copy[field] = redactProperties(value, options);\n }\n }\n return copy;\n}\n\n/**\n * Checks if a field should be redacted based on the provided field patterns.\n * @param field The field name to check.\n * @param fieldPatterns The field patterns to match against.\n * @returns `true` if the field should be redacted, `false` otherwise.\n * @since 0.10.0\n */\nexport function shouldFieldRedacted(\n field: string,\n fieldPatterns: FieldPatterns,\n): boolean {\n for (const fieldPattern of fieldPatterns) {\n if (typeof fieldPattern === \"string\") {\n if (fieldPattern === field) return true;\n } else {\n if (fieldPattern.test(field)) return true;\n }\n }\n return false;\n}\n"],"mappings":";;;;;;;AAsBA,MAAaA,wBAAuC;CAClD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACD;;;;;;;;;;;;;;;;;;;;;AAiDD,SAAgB,cACdC,MACAC,UAAiD,uBACE;CACnD,MAAM,OAAO,MAAM,QAAQ,QAAQ,GAAG,EAAE,eAAe,QAAS,IAAG;CACnE,MAAM,UAAU,CAACC,WAAsB;AACrC,OAAK;GAAE,GAAG;GAAQ,YAAY,iBAAiB,OAAO,YAAY,KAAK;EAAE,EAAC;CAC3E;AACD,KAAI,OAAO,WAAW,KAAM,SAAQ,OAAO,WAAW,KAAK,OAAO;AAClE,KAAI,OAAO,gBAAgB,KACzB,SAAQ,OAAO,gBAAgB,KAAK,OAAO;AAE7C,QAAO;AACR;;;;;;;;;;;;;;;;;AAkBD,SAAgB,iBACdC,YACAC,SACyB;CACzB,MAAM,OAAO,EAAE,GAAG,WAAY;AAC9B,MAAK,MAAM,SAAS,MAAM;AACxB,MAAI,oBAAoB,OAAO,QAAQ,cAAc,EAAE;AACrD,OAAI,QAAQ,UAAU,QAAQ,QAAQ,WAAW,SAC/C,QAAO,KAAK;OAEZ,MAAK,SAAS,QAAQ,OAAO,KAAK,OAAO;AAE3C;EACD;EACD,MAAM,QAAQ,KAAK;AAEnB,MAAI,MAAM,QAAQ,MAAM,CACtB,MAAK,SAAS,MAAM,IAAI,CAAC,SAAS;AAChC,cACS,SAAS,YAAY,SAAS,SACpC,OAAO,eAAe,KAAK,KAAK,OAAO,aACtC,OAAO,eAAe,KAAK,KAAK,MAGlC,QAAO,iBAAiB,MAAM,QAAQ;AAExC,UAAO;EACR,EAAC;kBAGK,UAAU,YAAY,UAAU,SACtC,OAAO,eAAe,MAAM,KAAK,OAAO,aACvC,OAAO,eAAe,MAAM,KAAK,MAGnC,MAAK,SAAS,iBAAiB,OAAO,QAAQ;CAEjD;AACD,QAAO;AACR;;;;;;;;AASD,SAAgB,oBACdC,OACAC,eACS;AACT,MAAK,MAAM,gBAAgB,cACzB,YAAW,iBAAiB,UAC1B;MAAI,iBAAiB,MAAO,QAAO;CAAK,WAEpC,aAAa,KAAK,MAAM,CAAE,QAAO;AAGzC,QAAO;AACR"}
|
|
1
|
+
{"version":3,"file":"field.js","names":["DEFAULT_REDACT_FIELDS: FieldPatterns","sink: Sink | Sink & Disposable | Sink & AsyncDisposable","options: FieldRedactionOptions | FieldPatterns","record: LogRecord","properties: Record<string, unknown>","options: FieldRedactionOptions","field: string","fieldPatterns: FieldPatterns","template: string","placeholders: string[]","path: string","segments: string[]","message: readonly unknown[]","redactedIndices: Set<number>","action: \"delete\" | ((value: unknown) => unknown) | undefined","result: unknown[]","original: Record<string, unknown>","redacted: Record<string, unknown>","map: Map<unknown, unknown>","redactedValues: Map<unknown, unknown>"],"sources":["../src/field.ts"],"sourcesContent":["import type { LogRecord, Sink } from \"@logtape/logtape\";\n\n/**\n * The type for a field pattern used in redaction. A string or a regular\n * expression that matches field names.\n * @since 0.10.0\n */\nexport type FieldPattern = string | RegExp;\n\n/**\n * An array of field patterns used for redaction. Each pattern can be\n * a string or a regular expression that matches field names.\n * @since 0.10.0\n */\nexport type FieldPatterns = FieldPattern[];\n\n/**\n * Default field patterns for redaction. These patterns will match\n * common sensitive fields such as passwords, tokens, and personal\n * information.\n * @since 0.10.0\n */\nexport const DEFAULT_REDACT_FIELDS: FieldPatterns = [\n /pass(?:code|phrase|word)/i,\n /secret/i,\n /token/i,\n /key/i,\n /credential/i,\n /auth/i,\n /signature/i,\n /sensitive/i,\n /private/i,\n /ssn/i,\n /email/i,\n /phone/i,\n /address/i,\n];\n\n/**\n * Options for redacting fields in a {@link LogRecord}. Used by\n * the {@link redactByField} function.\n * @since 0.10.0\n */\nexport interface FieldRedactionOptions {\n /**\n * The field patterns to match against. This can be an array of\n * strings or regular expressions. If a field matches any of the\n * patterns, it will be redacted.\n * @defaultValue {@link DEFAULT_REDACT_FIELDS}\n */\n readonly fieldPatterns: FieldPatterns;\n\n /**\n * The action to perform on the matched fields. If not provided,\n * the default action is to delete the field from the properties.\n * If a function is provided, it will be called with the\n * value of the field, and the return value will be used to replace\n * the field in the properties.\n * If the action is `\"delete\"`, the field will be removed from the\n * properties.\n * @default `\"delete\"`\n */\n readonly action?: \"delete\" | ((value: unknown) => unknown);\n}\n\n/**\n * Redacts properties and message values in a {@link LogRecord} based on the\n * provided field patterns and action.\n *\n * Note that it is a decorator which wraps the sink and redacts properties\n * and message values before passing them to the sink.\n *\n * For string templates (e.g., `\"Hello, {name}!\"`), placeholder names are\n * matched against the field patterns to determine which values to redact.\n *\n * For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction\n * is performed by comparing message values with redacted property values.\n *\n * @example\n * ```ts\n * import { getConsoleSink } from \"@logtape/logtape\";\n * import { redactByField } from \"@logtape/redaction\";\n *\n * const sink = redactByField(getConsoleSink());\n * ```\n *\n * @param sink The sink to wrap.\n * @param options The redaction options.\n * @returns The wrapped sink.\n * @since 0.10.0\n */\nexport function redactByField(\n sink: Sink | Sink & Disposable | Sink & AsyncDisposable,\n options: FieldRedactionOptions | FieldPatterns = DEFAULT_REDACT_FIELDS,\n): Sink | Sink & Disposable | Sink & AsyncDisposable {\n const opts = Array.isArray(options) ? { fieldPatterns: options } : options;\n const wrapped = (record: LogRecord) => {\n const redactedProperties = redactProperties(record.properties, opts);\n let redactedMessage = record.message;\n\n if (typeof record.rawMessage === \"string\") {\n // String template: redact by placeholder names\n const placeholders = extractPlaceholderNames(record.rawMessage);\n const redactedIndices = getRedactedPlaceholderIndices(\n placeholders,\n opts.fieldPatterns,\n );\n if (redactedIndices.size > 0) {\n redactedMessage = redactMessageArray(\n record.message,\n redactedIndices,\n opts.action,\n );\n }\n } else {\n // Tagged template: redact by comparing values\n const redactedValues = getRedactedValues(\n record.properties,\n redactedProperties,\n );\n if (redactedValues.size > 0) {\n redactedMessage = redactMessageByValues(record.message, redactedValues);\n }\n }\n\n sink({\n ...record,\n message: redactedMessage,\n properties: redactedProperties,\n });\n };\n if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];\n if (Symbol.asyncDispose in sink) {\n wrapped[Symbol.asyncDispose] = sink[Symbol.asyncDispose];\n }\n return wrapped;\n}\n\n/**\n * Redacts properties from an object based on specified field patterns.\n *\n * This function creates a shallow copy of the input object and applies\n * redaction rules to its properties. For properties that match the redaction\n * patterns, the function either removes them or transforms their values based\n * on the provided action.\n *\n * The redaction process is recursive and will be applied to nested objects\n * as well, allowing for deep redaction of sensitive data in complex object\n * structures.\n * @param properties The properties to redact.\n * @param options The redaction options.\n * @returns The redacted properties.\n * @since 0.10.0\n */\nexport function redactProperties(\n properties: Record<string, unknown>,\n options: FieldRedactionOptions,\n): Record<string, unknown> {\n const copy = { ...properties };\n for (const field in copy) {\n if (shouldFieldRedacted(field, options.fieldPatterns)) {\n if (options.action == null || options.action === \"delete\") {\n delete copy[field];\n } else {\n copy[field] = options.action(copy[field]);\n }\n continue;\n }\n const value = copy[field];\n // Check if value is an array:\n if (Array.isArray(value)) {\n copy[field] = value.map((item) => {\n if (\n typeof item === \"object\" && item !== null &&\n (Object.getPrototypeOf(item) === Object.prototype ||\n Object.getPrototypeOf(item) === null)\n ) {\n // @ts-ignore: item is always Record<string, unknown>\n return redactProperties(item, options);\n }\n return item;\n });\n // Check if value is a vanilla object:\n } else if (\n typeof value === \"object\" && value !== null &&\n (Object.getPrototypeOf(value) === Object.prototype ||\n Object.getPrototypeOf(value) === null)\n ) {\n // @ts-ignore: value is always Record<string, unknown>\n copy[field] = redactProperties(value, options);\n }\n }\n return copy;\n}\n\n/**\n * Checks if a field should be redacted based on the provided field patterns.\n * @param field The field name to check.\n * @param fieldPatterns The field patterns to match against.\n * @returns `true` if the field should be redacted, `false` otherwise.\n * @since 0.10.0\n */\nexport function shouldFieldRedacted(\n field: string,\n fieldPatterns: FieldPatterns,\n): boolean {\n for (const fieldPattern of fieldPatterns) {\n if (typeof fieldPattern === \"string\") {\n if (fieldPattern === field) return true;\n } else {\n if (fieldPattern.test(field)) return true;\n }\n }\n return false;\n}\n\n/**\n * Extracts placeholder names from a message template string in order.\n * @param template The message template string.\n * @returns An array of placeholder names in the order they appear.\n */\nfunction extractPlaceholderNames(template: string): string[] {\n const placeholders: string[] = [];\n for (let i = 0; i < template.length; i++) {\n if (template[i] === \"{\") {\n // Check for escaped brace\n if (i + 1 < template.length && template[i + 1] === \"{\") {\n i++;\n continue;\n }\n const closeIndex = template.indexOf(\"}\", i + 1);\n if (closeIndex === -1) continue;\n const key = template.slice(i + 1, closeIndex).trim();\n placeholders.push(key);\n i = closeIndex;\n }\n }\n return placeholders;\n}\n\n/**\n * Parses a property path into its segments.\n * @param path The property path (e.g., \"user.password\" or \"users[0].email\").\n * @returns An array of path segments.\n */\nfunction parsePathSegments(path: string): string[] {\n const segments: string[] = [];\n let current = \"\";\n for (const char of path) {\n if (char === \".\" || char === \"[\") {\n if (current) segments.push(current);\n current = \"\";\n } else if (char === \"]\" || char === \"?\") {\n // Skip these characters\n } else {\n current += char;\n }\n }\n if (current) segments.push(current);\n return segments;\n}\n\n/**\n * Determines which placeholder indices should be redacted based on field\n * patterns.\n * @param placeholders Array of placeholder names from the template.\n * @param fieldPatterns Field patterns to match against.\n * @returns Set of indices that should be redacted.\n */\nfunction getRedactedPlaceholderIndices(\n placeholders: string[],\n fieldPatterns: FieldPatterns,\n): Set<number> {\n const indices = new Set<number>();\n for (let i = 0; i < placeholders.length; i++) {\n const placeholder = placeholders[i];\n // Skip wildcard {*}\n if (placeholder === \"*\") continue;\n\n // Check the full placeholder name\n if (shouldFieldRedacted(placeholder, fieldPatterns)) {\n indices.add(i);\n continue;\n }\n // For nested paths, check each segment\n const segments = parsePathSegments(placeholder);\n for (const segment of segments) {\n if (shouldFieldRedacted(segment, fieldPatterns)) {\n indices.add(i);\n break;\n }\n }\n }\n return indices;\n}\n\n/**\n * Redacts values in the message array based on the redacted placeholder\n * indices.\n * @param message The original message array.\n * @param redactedIndices Set of placeholder indices to redact.\n * @param action The redaction action.\n * @returns New message array with redacted values.\n */\nfunction redactMessageArray(\n message: readonly unknown[],\n redactedIndices: Set<number>,\n action: \"delete\" | ((value: unknown) => unknown) | undefined,\n): readonly unknown[] {\n if (redactedIndices.size === 0) return message;\n\n const result: unknown[] = [];\n let placeholderIndex = 0;\n\n for (let i = 0; i < message.length; i++) {\n if (i % 2 === 0) {\n // Even index: text segment\n result.push(message[i]);\n } else {\n // Odd index: value/placeholder\n if (redactedIndices.has(placeholderIndex)) {\n if (action == null || action === \"delete\") {\n result.push(\"\");\n } else {\n result.push(action(message[i]));\n }\n } else {\n result.push(message[i]);\n }\n placeholderIndex++;\n }\n }\n return result;\n}\n\n/**\n * Collects redacted value mappings from original to redacted properties.\n * @param original The original properties.\n * @param redacted The redacted properties.\n * @param map The map to populate with original -> redacted value pairs.\n */\nfunction collectRedactedValues(\n original: Record<string, unknown>,\n redacted: Record<string, unknown>,\n map: Map<unknown, unknown>,\n): void {\n for (const key in original) {\n const origVal = original[key];\n const redVal = redacted[key];\n\n if (origVal !== redVal) {\n map.set(origVal, redVal);\n }\n\n // Recurse into nested objects\n if (\n typeof origVal === \"object\" && origVal !== null &&\n typeof redVal === \"object\" && redVal !== null &&\n !Array.isArray(origVal)\n ) {\n collectRedactedValues(\n origVal as Record<string, unknown>,\n redVal as Record<string, unknown>,\n map,\n );\n }\n }\n}\n\n/**\n * Gets a map of original values to their redacted replacements.\n * @param original The original properties.\n * @param redacted The redacted properties.\n * @returns A map of original -> redacted values.\n */\nfunction getRedactedValues(\n original: Record<string, unknown>,\n redacted: Record<string, unknown>,\n): Map<unknown, unknown> {\n const map = new Map<unknown, unknown>();\n collectRedactedValues(original, redacted, map);\n return map;\n}\n\n/**\n * Redacts message array values by comparing with redacted property values.\n * Used for tagged template literals where placeholder names are not available.\n * @param message The original message array.\n * @param redactedValues Map of original -> redacted values.\n * @returns New message array with redacted values.\n */\nfunction redactMessageByValues(\n message: readonly unknown[],\n redactedValues: Map<unknown, unknown>,\n): readonly unknown[] {\n if (redactedValues.size === 0) return message;\n\n const result: unknown[] = [];\n for (let i = 0; i < message.length; i++) {\n if (i % 2 === 0) {\n result.push(message[i]);\n } else {\n const val = message[i];\n if (redactedValues.has(val)) {\n result.push(redactedValues.get(val));\n } else {\n result.push(val);\n }\n }\n }\n return result;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logtape/redaction",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Redact sensitive data from log messages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"logging",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"sideEffects": false,
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@logtape/logtape": "^1.2.
|
|
49
|
+
"@logtape/logtape": "^1.2.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@alinea/suite": "^0.6.3",
|
package/src/field.test.ts
CHANGED
|
@@ -341,4 +341,204 @@ test("redactByField()", async () => {
|
|
|
341
341
|
const configs = records[0].properties.configs as any;
|
|
342
342
|
assertEquals(configs[0], { username: "user" });
|
|
343
343
|
}
|
|
344
|
+
|
|
345
|
+
{ // redacts values in message array (string template)
|
|
346
|
+
const records: LogRecord[] = [];
|
|
347
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
348
|
+
fieldPatterns: ["password"],
|
|
349
|
+
action: () => "[REDACTED]",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
wrappedSink({
|
|
353
|
+
level: "info",
|
|
354
|
+
category: ["test"],
|
|
355
|
+
message: ["Password is ", "supersecret", ""],
|
|
356
|
+
rawMessage: "Password is {password}",
|
|
357
|
+
timestamp: Date.now(),
|
|
358
|
+
properties: { password: "supersecret" },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
assertEquals(records[0].message, ["Password is ", "[REDACTED]", ""]);
|
|
362
|
+
assertEquals(records[0].properties.password, "[REDACTED]");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
{ // redacts multiple sensitive fields in message
|
|
366
|
+
const records: LogRecord[] = [];
|
|
367
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
368
|
+
fieldPatterns: ["password", "email"],
|
|
369
|
+
action: () => "[REDACTED]",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
wrappedSink({
|
|
373
|
+
level: "info",
|
|
374
|
+
category: ["test"],
|
|
375
|
+
message: ["Login: ", "user@example.com", " with ", "secret123", ""],
|
|
376
|
+
rawMessage: "Login: {email} with {password}",
|
|
377
|
+
timestamp: Date.now(),
|
|
378
|
+
properties: { email: "user@example.com", password: "secret123" },
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
382
|
+
assertEquals(records[0].message[3], "[REDACTED]");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
{ // redacts nested property path in message
|
|
386
|
+
const records: LogRecord[] = [];
|
|
387
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
388
|
+
fieldPatterns: ["password"],
|
|
389
|
+
action: () => "[REDACTED]",
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
wrappedSink({
|
|
393
|
+
level: "info",
|
|
394
|
+
category: ["test"],
|
|
395
|
+
message: ["User password: ", "secret", ""],
|
|
396
|
+
rawMessage: "User password: {user.password}",
|
|
397
|
+
timestamp: Date.now(),
|
|
398
|
+
properties: { user: { password: "secret" } },
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
{ // delete action uses empty string in message
|
|
405
|
+
const records: LogRecord[] = [];
|
|
406
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
407
|
+
fieldPatterns: ["password"],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
wrappedSink({
|
|
411
|
+
level: "info",
|
|
412
|
+
category: ["test"],
|
|
413
|
+
message: ["Password: ", "secret", ""],
|
|
414
|
+
rawMessage: "Password: {password}",
|
|
415
|
+
timestamp: Date.now(),
|
|
416
|
+
properties: { password: "secret" },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
assertEquals(records[0].message[1], "");
|
|
420
|
+
assertFalse("password" in records[0].properties);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
{ // non-sensitive field in message is not redacted
|
|
424
|
+
const records: LogRecord[] = [];
|
|
425
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
426
|
+
fieldPatterns: ["password"],
|
|
427
|
+
action: () => "[REDACTED]",
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
wrappedSink({
|
|
431
|
+
level: "info",
|
|
432
|
+
category: ["test"],
|
|
433
|
+
message: ["Username: ", "johndoe", ""],
|
|
434
|
+
rawMessage: "Username: {username}",
|
|
435
|
+
timestamp: Date.now(),
|
|
436
|
+
properties: { username: "johndoe" },
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
assertEquals(records[0].message[1], "johndoe");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
{ // wildcard {*} in message is not redacted
|
|
443
|
+
const records: LogRecord[] = [];
|
|
444
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
445
|
+
fieldPatterns: ["password"],
|
|
446
|
+
action: () => "[REDACTED]",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const props = { username: "john", password: "secret" };
|
|
450
|
+
wrappedSink({
|
|
451
|
+
level: "info",
|
|
452
|
+
category: ["test"],
|
|
453
|
+
message: ["Props: ", props, ""],
|
|
454
|
+
rawMessage: "Props: {*}",
|
|
455
|
+
timestamp: Date.now(),
|
|
456
|
+
properties: props,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// The {*} itself should not be redacted
|
|
460
|
+
assertEquals(records[0].message[1], props);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
{ // escaped braces are not treated as placeholders
|
|
464
|
+
const records: LogRecord[] = [];
|
|
465
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
466
|
+
fieldPatterns: ["password"],
|
|
467
|
+
action: () => "[REDACTED]",
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
wrappedSink({
|
|
471
|
+
level: "info",
|
|
472
|
+
category: ["test"],
|
|
473
|
+
message: ["Value: ", "secret", ""],
|
|
474
|
+
rawMessage: "Value: {{password}} {password}",
|
|
475
|
+
timestamp: Date.now(),
|
|
476
|
+
properties: { password: "secret" },
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Only the second {password} is a placeholder
|
|
480
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
{ // tagged template literal - redacts by comparing values
|
|
484
|
+
const records: LogRecord[] = [];
|
|
485
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
486
|
+
fieldPatterns: ["password"],
|
|
487
|
+
action: () => "[REDACTED]",
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const rawMessage = ["Password: ", ""] as unknown as TemplateStringsArray;
|
|
491
|
+
Object.defineProperty(rawMessage, "raw", { value: rawMessage });
|
|
492
|
+
|
|
493
|
+
wrappedSink({
|
|
494
|
+
level: "info",
|
|
495
|
+
category: ["test"],
|
|
496
|
+
message: ["Password: ", "secret", ""],
|
|
497
|
+
rawMessage,
|
|
498
|
+
timestamp: Date.now(),
|
|
499
|
+
properties: { password: "secret" },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Message should be redacted by value comparison
|
|
503
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
504
|
+
assertEquals(records[0].properties.password, "[REDACTED]");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
{ // array access path in message
|
|
508
|
+
const records: LogRecord[] = [];
|
|
509
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
510
|
+
fieldPatterns: ["password"],
|
|
511
|
+
action: () => "[REDACTED]",
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
wrappedSink({
|
|
515
|
+
level: "info",
|
|
516
|
+
category: ["test"],
|
|
517
|
+
message: ["First user password: ", "secret1", ""],
|
|
518
|
+
rawMessage: "First user password: {users[0].password}",
|
|
519
|
+
timestamp: Date.now(),
|
|
520
|
+
properties: { users: [{ password: "secret1" }] },
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
{ // regex pattern matches in message placeholder
|
|
527
|
+
const records: LogRecord[] = [];
|
|
528
|
+
const wrappedSink = redactByField((r) => records.push(r), {
|
|
529
|
+
fieldPatterns: [/pass/i],
|
|
530
|
+
action: () => "[REDACTED]",
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
wrappedSink({
|
|
534
|
+
level: "info",
|
|
535
|
+
category: ["test"],
|
|
536
|
+
message: ["Passphrase: ", "mysecret", ""],
|
|
537
|
+
rawMessage: "Passphrase: {passphrase}",
|
|
538
|
+
timestamp: Date.now(),
|
|
539
|
+
properties: { passphrase: "mysecret" },
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
assertEquals(records[0].message[1], "[REDACTED]");
|
|
543
|
+
}
|
|
344
544
|
});
|
package/src/field.ts
CHANGED
|
@@ -64,11 +64,17 @@ export interface FieldRedactionOptions {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Redacts properties in a {@link LogRecord} based on the
|
|
68
|
-
* patterns and action.
|
|
67
|
+
* Redacts properties and message values in a {@link LogRecord} based on the
|
|
68
|
+
* provided field patterns and action.
|
|
69
69
|
*
|
|
70
70
|
* Note that it is a decorator which wraps the sink and redacts properties
|
|
71
|
-
* before passing them to the sink.
|
|
71
|
+
* and message values before passing them to the sink.
|
|
72
|
+
*
|
|
73
|
+
* For string templates (e.g., `"Hello, {name}!"`), placeholder names are
|
|
74
|
+
* matched against the field patterns to determine which values to redact.
|
|
75
|
+
*
|
|
76
|
+
* For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
|
|
77
|
+
* is performed by comparing message values with redacted property values.
|
|
72
78
|
*
|
|
73
79
|
* @example
|
|
74
80
|
* ```ts
|
|
@@ -89,7 +95,39 @@ export function redactByField(
|
|
|
89
95
|
): Sink | Sink & Disposable | Sink & AsyncDisposable {
|
|
90
96
|
const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
|
|
91
97
|
const wrapped = (record: LogRecord) => {
|
|
92
|
-
|
|
98
|
+
const redactedProperties = redactProperties(record.properties, opts);
|
|
99
|
+
let redactedMessage = record.message;
|
|
100
|
+
|
|
101
|
+
if (typeof record.rawMessage === "string") {
|
|
102
|
+
// String template: redact by placeholder names
|
|
103
|
+
const placeholders = extractPlaceholderNames(record.rawMessage);
|
|
104
|
+
const redactedIndices = getRedactedPlaceholderIndices(
|
|
105
|
+
placeholders,
|
|
106
|
+
opts.fieldPatterns,
|
|
107
|
+
);
|
|
108
|
+
if (redactedIndices.size > 0) {
|
|
109
|
+
redactedMessage = redactMessageArray(
|
|
110
|
+
record.message,
|
|
111
|
+
redactedIndices,
|
|
112
|
+
opts.action,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// Tagged template: redact by comparing values
|
|
117
|
+
const redactedValues = getRedactedValues(
|
|
118
|
+
record.properties,
|
|
119
|
+
redactedProperties,
|
|
120
|
+
);
|
|
121
|
+
if (redactedValues.size > 0) {
|
|
122
|
+
redactedMessage = redactMessageByValues(record.message, redactedValues);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sink({
|
|
127
|
+
...record,
|
|
128
|
+
message: redactedMessage,
|
|
129
|
+
properties: redactedProperties,
|
|
130
|
+
});
|
|
93
131
|
};
|
|
94
132
|
if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];
|
|
95
133
|
if (Symbol.asyncDispose in sink) {
|
|
@@ -175,3 +213,200 @@ export function shouldFieldRedacted(
|
|
|
175
213
|
}
|
|
176
214
|
return false;
|
|
177
215
|
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Extracts placeholder names from a message template string in order.
|
|
219
|
+
* @param template The message template string.
|
|
220
|
+
* @returns An array of placeholder names in the order they appear.
|
|
221
|
+
*/
|
|
222
|
+
function extractPlaceholderNames(template: string): string[] {
|
|
223
|
+
const placeholders: string[] = [];
|
|
224
|
+
for (let i = 0; i < template.length; i++) {
|
|
225
|
+
if (template[i] === "{") {
|
|
226
|
+
// Check for escaped brace
|
|
227
|
+
if (i + 1 < template.length && template[i + 1] === "{") {
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const closeIndex = template.indexOf("}", i + 1);
|
|
232
|
+
if (closeIndex === -1) continue;
|
|
233
|
+
const key = template.slice(i + 1, closeIndex).trim();
|
|
234
|
+
placeholders.push(key);
|
|
235
|
+
i = closeIndex;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return placeholders;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parses a property path into its segments.
|
|
243
|
+
* @param path The property path (e.g., "user.password" or "users[0].email").
|
|
244
|
+
* @returns An array of path segments.
|
|
245
|
+
*/
|
|
246
|
+
function parsePathSegments(path: string): string[] {
|
|
247
|
+
const segments: string[] = [];
|
|
248
|
+
let current = "";
|
|
249
|
+
for (const char of path) {
|
|
250
|
+
if (char === "." || char === "[") {
|
|
251
|
+
if (current) segments.push(current);
|
|
252
|
+
current = "";
|
|
253
|
+
} else if (char === "]" || char === "?") {
|
|
254
|
+
// Skip these characters
|
|
255
|
+
} else {
|
|
256
|
+
current += char;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (current) segments.push(current);
|
|
260
|
+
return segments;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Determines which placeholder indices should be redacted based on field
|
|
265
|
+
* patterns.
|
|
266
|
+
* @param placeholders Array of placeholder names from the template.
|
|
267
|
+
* @param fieldPatterns Field patterns to match against.
|
|
268
|
+
* @returns Set of indices that should be redacted.
|
|
269
|
+
*/
|
|
270
|
+
function getRedactedPlaceholderIndices(
|
|
271
|
+
placeholders: string[],
|
|
272
|
+
fieldPatterns: FieldPatterns,
|
|
273
|
+
): Set<number> {
|
|
274
|
+
const indices = new Set<number>();
|
|
275
|
+
for (let i = 0; i < placeholders.length; i++) {
|
|
276
|
+
const placeholder = placeholders[i];
|
|
277
|
+
// Skip wildcard {*}
|
|
278
|
+
if (placeholder === "*") continue;
|
|
279
|
+
|
|
280
|
+
// Check the full placeholder name
|
|
281
|
+
if (shouldFieldRedacted(placeholder, fieldPatterns)) {
|
|
282
|
+
indices.add(i);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// For nested paths, check each segment
|
|
286
|
+
const segments = parsePathSegments(placeholder);
|
|
287
|
+
for (const segment of segments) {
|
|
288
|
+
if (shouldFieldRedacted(segment, fieldPatterns)) {
|
|
289
|
+
indices.add(i);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return indices;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Redacts values in the message array based on the redacted placeholder
|
|
299
|
+
* indices.
|
|
300
|
+
* @param message The original message array.
|
|
301
|
+
* @param redactedIndices Set of placeholder indices to redact.
|
|
302
|
+
* @param action The redaction action.
|
|
303
|
+
* @returns New message array with redacted values.
|
|
304
|
+
*/
|
|
305
|
+
function redactMessageArray(
|
|
306
|
+
message: readonly unknown[],
|
|
307
|
+
redactedIndices: Set<number>,
|
|
308
|
+
action: "delete" | ((value: unknown) => unknown) | undefined,
|
|
309
|
+
): readonly unknown[] {
|
|
310
|
+
if (redactedIndices.size === 0) return message;
|
|
311
|
+
|
|
312
|
+
const result: unknown[] = [];
|
|
313
|
+
let placeholderIndex = 0;
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < message.length; i++) {
|
|
316
|
+
if (i % 2 === 0) {
|
|
317
|
+
// Even index: text segment
|
|
318
|
+
result.push(message[i]);
|
|
319
|
+
} else {
|
|
320
|
+
// Odd index: value/placeholder
|
|
321
|
+
if (redactedIndices.has(placeholderIndex)) {
|
|
322
|
+
if (action == null || action === "delete") {
|
|
323
|
+
result.push("");
|
|
324
|
+
} else {
|
|
325
|
+
result.push(action(message[i]));
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
result.push(message[i]);
|
|
329
|
+
}
|
|
330
|
+
placeholderIndex++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Collects redacted value mappings from original to redacted properties.
|
|
338
|
+
* @param original The original properties.
|
|
339
|
+
* @param redacted The redacted properties.
|
|
340
|
+
* @param map The map to populate with original -> redacted value pairs.
|
|
341
|
+
*/
|
|
342
|
+
function collectRedactedValues(
|
|
343
|
+
original: Record<string, unknown>,
|
|
344
|
+
redacted: Record<string, unknown>,
|
|
345
|
+
map: Map<unknown, unknown>,
|
|
346
|
+
): void {
|
|
347
|
+
for (const key in original) {
|
|
348
|
+
const origVal = original[key];
|
|
349
|
+
const redVal = redacted[key];
|
|
350
|
+
|
|
351
|
+
if (origVal !== redVal) {
|
|
352
|
+
map.set(origVal, redVal);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Recurse into nested objects
|
|
356
|
+
if (
|
|
357
|
+
typeof origVal === "object" && origVal !== null &&
|
|
358
|
+
typeof redVal === "object" && redVal !== null &&
|
|
359
|
+
!Array.isArray(origVal)
|
|
360
|
+
) {
|
|
361
|
+
collectRedactedValues(
|
|
362
|
+
origVal as Record<string, unknown>,
|
|
363
|
+
redVal as Record<string, unknown>,
|
|
364
|
+
map,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Gets a map of original values to their redacted replacements.
|
|
372
|
+
* @param original The original properties.
|
|
373
|
+
* @param redacted The redacted properties.
|
|
374
|
+
* @returns A map of original -> redacted values.
|
|
375
|
+
*/
|
|
376
|
+
function getRedactedValues(
|
|
377
|
+
original: Record<string, unknown>,
|
|
378
|
+
redacted: Record<string, unknown>,
|
|
379
|
+
): Map<unknown, unknown> {
|
|
380
|
+
const map = new Map<unknown, unknown>();
|
|
381
|
+
collectRedactedValues(original, redacted, map);
|
|
382
|
+
return map;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Redacts message array values by comparing with redacted property values.
|
|
387
|
+
* Used for tagged template literals where placeholder names are not available.
|
|
388
|
+
* @param message The original message array.
|
|
389
|
+
* @param redactedValues Map of original -> redacted values.
|
|
390
|
+
* @returns New message array with redacted values.
|
|
391
|
+
*/
|
|
392
|
+
function redactMessageByValues(
|
|
393
|
+
message: readonly unknown[],
|
|
394
|
+
redactedValues: Map<unknown, unknown>,
|
|
395
|
+
): readonly unknown[] {
|
|
396
|
+
if (redactedValues.size === 0) return message;
|
|
397
|
+
|
|
398
|
+
const result: unknown[] = [];
|
|
399
|
+
for (let i = 0; i < message.length; i++) {
|
|
400
|
+
if (i % 2 === 0) {
|
|
401
|
+
result.push(message[i]);
|
|
402
|
+
} else {
|
|
403
|
+
const val = message[i];
|
|
404
|
+
if (redactedValues.has(val)) {
|
|
405
|
+
result.push(redactedValues.get(val));
|
|
406
|
+
} else {
|
|
407
|
+
result.push(val);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}
|