@nestjs/common
Version:
Nest - modern, fast, powerful node.js web framework (@common)
226 lines (225 loc) • 8.54 kB
JavaScript
export const DEFAULT_REDACT_CENSOR = '[REDACTED]';
const CIRCULAR = '[Circular]';
/**
* Creates a function that masks properties of logged values.
*
* - A path without dots (`password`) matches a property with that name at any
* depth.
* - A dotted path (`user.password`) matches when the last keys leading to a
* property are those keys, at any depth. Array indices are not keys, so
* `users.password` also matches `{ users: [{ password }] }`.
* - Keys are compared case-insensitively.
*
* Plain objects, arrays, class instances, errors (including `cause` and
* `AggregateError#errors`), `Map`s (string keys) and `Set`s are traversed.
* Only the objects on the way to a redacted property are copied; the rest is
* returned as is, and the input is never mutated. A circular reference is
* replaced with `"[Circular]"` (so the objects on the cycle are copied too). If
* the value can't be traversed (for example, a getter throws), the whole value
* is replaced with the censor.
*
* @returns `undefined` when there is nothing to redact.
*/
export function createRedactor(paths, censor = DEFAULT_REDACT_CENSOR) {
const keys = new Set();
const dottedPaths = [];
for (const path of paths) {
const segments = path
.split('.')
.map(segment => segment.trim().toLowerCase())
.filter(segment => segment.length > 0);
if (segments.length === 1) {
keys.add(segments[0]);
}
else if (segments.length > 1) {
dottedPaths.push(segments);
}
}
if (keys.size === 0 && dottedPaths.length === 0) {
return undefined;
}
return (value) => {
// Lower-cased keys from the root to the value being visited.
const path = [];
// Objects on that path, to detect circular references.
const ancestors = [];
const isRedacted = (key) => {
if (keys.has(key)) {
return true;
}
return dottedPaths.some(segments => {
const last = segments.length - 1;
if (segments[last] !== key || last > path.length) {
return false;
}
for (let i = 1; i <= last; i++) {
if (segments[last - i] !== path[path.length - i]) {
return false;
}
}
return true;
});
};
// Each call starts with a fresh "path" and "ancestors", so an exception
// (caught below) doesn't need to restore them.
const visitProperty = (key, propertyValue) => {
const normalizedKey = key.toLowerCase();
if (isRedacted(normalizedKey)) {
return censor;
}
if (typeof propertyValue !== 'object' || propertyValue === null) {
return propertyValue;
}
path.push(normalizedKey);
const redacted = visit(propertyValue);
path.pop();
return redacted;
};
const visit = (current) => {
if (typeof current !== 'object' || current === null) {
return current;
}
if (ancestors.includes(current)) {
return CIRCULAR;
}
let redacted;
ancestors.push(current);
if (Array.isArray(current)) {
redacted = redactArray(current);
}
else if (Object.getPrototypeOf(current) === Object.prototype) {
redacted = redactObject(current);
}
else if (current instanceof Map) {
redacted = redactMap(current);
}
else if (current instanceof Set) {
redacted = redactSet(current);
}
else if (isOpaque(current)) {
redacted = current;
}
else {
redacted = redactObject(current);
}
ancestors.pop();
return redacted;
};
const redactArray = (array) => {
let copy;
for (let i = 0; i < array.length; i++) {
const redacted = visit(array[i]);
if (!Object.is(redacted, array[i])) {
copy ??= array.slice();
copy[i] = redacted;
}
}
return copy ?? array;
};
const redactMap = (map) => {
let changed = false;
const entries = [];
for (const [key, entryValue] of map) {
const redacted = typeof key === 'string'
? visitProperty(key, entryValue)
: visit(entryValue);
changed ||= !Object.is(redacted, entryValue);
entries.push([key, redacted]);
}
return changed ? new Map(entries) : map;
};
const redactSet = (set) => {
let changed = false;
const values = [];
for (const setValue of set) {
const redacted = visit(setValue);
changed ||= !Object.is(redacted, setValue);
values.push(redacted);
}
return changed ? new Set(values) : set;
};
const redactObject = (object) => {
const source = object;
const keysToVisit = Object.keys(source);
if (object instanceof Error) {
// Non-enumerable, but part of the logged error.
for (const key of ['cause', 'errors']) {
if (Object.prototype.hasOwnProperty.call(object, key) &&
!keysToVisit.includes(key)) {
keysToVisit.push(key);
}
}
}
let changes;
for (const key of keysToVisit) {
const original = source[key];
const redacted = visitProperty(key, original);
if (!Object.is(redacted, original)) {
changes ??= new Map();
changes.set(key, redacted);
}
}
if (!changes) {
return object;
}
if (Object.getPrototypeOf(object) === Object.prototype) {
const copy = { ...source };
for (const [key, redacted] of changes) {
if (key === '__proto__') {
// An assignment would call the "__proto__" setter.
Object.defineProperty(copy, key, {
value: redacted,
enumerable: true,
writable: true,
configurable: true,
});
}
else {
copy[key] = redacted;
}
}
return copy;
}
// Keeps the prototype and the other properties (including the
// non-enumerable "message" and "stack" of errors).
const descriptors = Object.getOwnPropertyDescriptors(object);
if (object instanceof Error && descriptors.stack?.get) {
// V8 may define "stack" as an accessor that only works on the
// original error.
descriptors.stack = {
value: object.stack,
enumerable: descriptors.stack.enumerable,
writable: true,
configurable: true,
};
}
for (const [key, redacted] of changes) {
descriptors[key] = {
value: redacted,
enumerable: descriptors[key].enumerable,
writable: true,
configurable: true,
};
}
return Object.create(Object.getPrototypeOf(object), descriptors);
};
try {
return visit(value);
}
catch {
return censor;
}
};
}
/**
* Objects whose content isn't made of properties worth traversing.
*/
function isOpaque(value) {
return (value instanceof Date ||
value instanceof RegExp ||
value instanceof Promise ||
value instanceof WeakMap ||
value instanceof WeakSet ||
value instanceof ArrayBuffer ||
ArrayBuffer.isView(value));
}