Files
suaveplan/packages/observability/monitor/logger/src/redaction/redaction.ts
2026-05-27 23:47:53 +01:00

132 lines
3.4 KiB
TypeScript

import { isSensitive } from "@suaveplan/error/redact";
import type {
PathRedactionRule,
PatternRedactionRule,
RedactionOptions,
RedactionRule,
} from "./redaction.types.js";
const REDACTED = "[REDACTED]";
const DEFAULT_MAX_DEPTH = 10;
function pathMatches(rulePath: string, keyPath: string): boolean {
const ruleSegments = rulePath.split(".");
const keySegments = keyPath.split(".");
if (ruleSegments.length !== keySegments.length) return false;
for (let i = 0; i < ruleSegments.length; i++) {
if (ruleSegments[i] !== "*" && ruleSegments[i] !== keySegments[i]) {
return false;
}
}
return true;
}
function shouldRedactPath(
rules: PathRedactionRule[],
keyPath: string
): boolean {
return rules.some((r) => pathMatches(r.path, keyPath));
}
function shouldRedactPattern(
rules: PatternRedactionRule[],
key: string
): boolean {
return rules.some((r) => r.pattern.test(key));
}
function redactValue(
value: unknown,
pathRules: PathRedactionRule[],
patternRules: PatternRedactionRule[],
label: string,
currentPath: string,
depth: number,
maxDepth: number
): unknown {
if (isSensitive(value)) {
return label;
}
if (
shouldRedactPath(pathRules, currentPath) ||
(currentPath !== "" &&
shouldRedactPattern(patternRules, currentPath.split(".").at(-1)!))
) {
return label;
}
if (depth >= maxDepth || value === null || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map((item, i) =>
redactValue(
item,
pathRules,
patternRules,
label,
`${currentPath}[${i}]`,
depth + 1,
maxDepth
)
);
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const childPath = currentPath === "" ? k : `${currentPath}.${k}`;
out[k] = redactValue(
v,
pathRules,
patternRules,
label,
childPath,
depth + 1,
maxDepth
);
}
return out;
}
export class RedactionEngine {
private readonly pathRules: PathRedactionRule[];
private readonly patternRules: PatternRedactionRule[];
private readonly label: string;
private readonly maxDepth: number;
public constructor(options: RedactionOptions = {}) {
const rules: RedactionRule[] = options.rules ?? [];
this.pathRules = rules.filter(
(r): r is PathRedactionRule => r.kind === "path"
);
this.patternRules = rules.filter(
(r): r is PatternRedactionRule => r.kind === "pattern"
);
this.label = options.redactedLabel ?? REDACTED;
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
}
public redact(ctx: Record<string, unknown>): Record<string, unknown> {
return redactValue(
ctx,
this.pathRules,
this.patternRules,
this.label,
"",
0,
this.maxDepth
) as Record<string, unknown>;
}
}
export function applyRedaction(
ctx: Record<string, unknown>,
options: RedactionOptions = {}
): Record<string, unknown> {
return new RedactionEngine(options).redact(ctx);
}
export type { RedactionOptions, RedactionRule };