132 lines
3.4 KiB
TypeScript
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 };
|