Skip to content

Commit

Permalink
chore: write tests for redact function
Browse files Browse the repository at this point in the history
  • Loading branch information
eliassjogreen committed Feb 22, 2024
1 parent ee4efa0 commit 915baed
Show file tree
Hide file tree
Showing 2 changed files with 216 additions and 86 deletions.
125 changes: 105 additions & 20 deletions transforms/redact.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,113 @@
import { assertEquals } from "jsr:@std/assert";
import { redact, redacted } from "./redact.ts";

Deno.test("redact", async ({ step }) => {
await step("redacts string literal from a shallow array", () => {
const data = ["foo", "bar", "baz"];
redact(data, { values: ["bar"] });
assertEquals(data, ["foo", redacted, "baz"]);
// deno-lint-ignore-file no-explicit-any
import { assertEquals, assertStrictEquals } from "jsr:@std/assert";

import { redact, redacted, secret } from "./redact.ts";

Deno.test("redacts string literal from a shallow array", () => {
const data = ["foo", "bar", "baz"];
assertEquals(redact(data, { values: ["bar"] }), ["foo", redacted, "baz"]);
assertEquals(data, ["foo", redacted, "baz"]);
});

Deno.test("redacts string match from a shallow array", () => {
const data = ["foobarbaz"];
assertEquals(redact(data, { values: ["bar"] }), ["fooredactedbaz"]);
assertEquals(data, ["fooredactedbaz"]);
});

Deno.test("redacts regexp match from a shallow array", () => {
const data = ["foo", "bar", "baz"];
assertEquals(redact(data, { values: [/bar/] }), ["foo", redacted, "baz"]);
assertEquals(data, ["foo", redacted, "baz"]);
});

Deno.test("redacts partial regexp match from a shallow array", () => {
const data = ["foo", "barstool", "baz"];
assertEquals(redact(data, { values: [/bar/] }), [
"foo",
"redactedstool",
"baz",
]);
assertEquals(data, ["foo", "redactedstool", "baz"]);
});

Deno.test("redacts partial string match from a shallow array", () => {
const data = ["foo", "barstool", "baz"];
assertEquals(redact(data, { values: ["bar"] }), [
"foo",
"redactedstool",
"baz",
]);
assertEquals(data, ["foo", "redactedstool", "baz"]);
});

Deno.test("secret marker", async ({ step }) => {
await step("redacts and clears array when marker is found", () => {
const data: any = ["foo", "bar", "baz"];
Object.defineProperty(data, secret, {});
assertStrictEquals(redact(data), redacted);
assertEquals(data, []);
});

await step("redacts nested array when marker is found", () => {
const data: any = ["foo", "bar", "baz", ["foo", "bar", "baz"]];
Object.defineProperty(data[3], secret, {});
assertEquals(redact(data), ["foo", "bar", "baz", redacted]);
assertEquals(data, ["foo", "bar", "baz", redacted]);
});

await step("redacts string match from a shallow array", () => {
const data = ["foobarbaz"];
redact(data, { values: ["bar"] });
assertEquals(data, ["fooredactedbaz"]);
await step("redacts and clears object when marker is found", () => {
const data: any = { foo: "bar" };
Object.defineProperty(data, secret, {});
assertStrictEquals(redact(data), redacted);
assertEquals(data, {});
});

await step("redacts regexp match from a shallow array", () => {
const data = ["foo", "bar", "baz"];
redact(data, { values: [/bar/] });
assertEquals(data, ["foo", redacted, "baz"]);
await step("redacts nested object when marker is found", () => {
const data: any = { foo: "bar", baz: { foo: "bar" } };
Object.defineProperty(data.baz, secret, {});
assertEquals(redact(data), { foo: "bar", baz: redacted });
assertEquals(data, { foo: "bar", baz: redacted });
});
});

await step("redacts partial regexp match from a shallow array", () => {
const data = ["foo", "barstool", "baz"];
redact(data, { values: [/bar/] });
assertEquals(data, ["foo", "redactedstool", "baz"]);
Deno.test("redacts string property from object", () => {
const data: any = { password: "this is secret" };
assertEquals(redact(data, { properties: ["password"] }), {
password: redacted,
});
assertEquals(data, { password: redacted });
});

Deno.test("redacts symbol property from object", () => {
const data: any = { [Symbol.for("foo")]: "bar" };
assertEquals(redact(data, { properties: [Symbol.for("foo")] }), {
[Symbol.for("foo")]: redacted,
});
assertEquals(data, { [Symbol.for("foo")]: redacted });
});

Deno.test("redacts number property from object", () => {
const data: any = { [0]: "bar" };
assertEquals(redact(data, { properties: [0] }), { [0]: redacted });
assertEquals(data, { [0]: redacted });
});

Deno.test("redacts symbol property from array", () => {
const data: any = [];
data[Symbol.for("foo")] = "bar";
redact(data, { properties: [Symbol.for("foo")] });
assertEquals(data[Symbol.for("foo")], redacted);
});

Deno.test("redacts number property from array", () => {
const data: any = ["foo", "bar"];
assertEquals(redact(data, { properties: [0] }), [redacted, "bar"]);
assertEquals(data, [redacted, "bar"]);
});

Deno.test("redacts whole line regexp match", () => {
const data = ["foo", "bar", "baz"];
assertEquals(redact(data, { values: [/^bar$/] }), ["foo", redacted, "baz"]);
assertEquals(data, ["foo", redacted, "baz"]);
});
177 changes: 111 additions & 66 deletions transforms/redact.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import { Log } from "../mod.ts";

export interface RedactOptions {
export interface RedactOptions<T = typeof redacted> {
/**
* A list of marker symbols which when encountered will replace the whole
* object with the `replace` value.
*
* @default [Symbol.for("secret")]
*/
markers?: symbol | symbol[];
/**
* A list of properties to redact their values from when encountered.
*/
properties?: (string | symbol | number)[];
/**
* A list of values to redact from the data when encountered.
*/
values?: unknown[];
replace?: unknown;
/**
* The value to replace redacted data with.
*
* @default Symbol.for("redacted")
*/
replace?: T;
}

interface PreparedRedactOptions {
interface InnerRedactOptions {
properties: string[];
symbols: symbol[];
markers: symbol[];

tester?: RegExp;
matcher?: RegExp;
Expand All @@ -22,90 +41,97 @@ function escapeRegexp(regexp: string): string {
return regexp.replace(/([()[{*+.$^\\|?])/g, "\\$1");
}

function innerRedact(data: any, options: PreparedRedactOptions) {
if (typeof data !== "object" || data === null) {
throw new TypeError("Can only redact from objects or arrays");
function redactProperty(
// deno-lint-ignore no-explicit-any
data: any,
property: string | symbol,
options: InnerRedactOptions,
) {
if (options.literals.includes(data[property])) {
data[property] = options.replaceValue;
return;
}

const properties = Object.getOwnPropertyNames(data);
const symbols = Object.getOwnPropertySymbols(data);

for (const property of properties) {
if (options.properties.includes(property)) {
Object.defineProperty(data, property, {
get: () => options.replaceValue,
});
if (typeof data[property] === "object") {
data[property] = innerRedact(data[property], options);
if (data[property] === options.replaceValue) {
return;
}
}

if (options.literals.includes(data[property])) {
if (typeof data[property] === "string") {
if (options.tester?.test(data[property])) {
data[property] = options.replaceValue;
continue;
return;
}

if (typeof data[property] === "object") {
innerRedact(data[property], options);
}
if (options.matcher !== undefined) {
data[property] = data[property].replaceAll(
options.matcher,
options.replaceString,
);

if (typeof data[property] === "string") {
if (options.tester?.test(data[property])) {
if (data[property] === options.replaceString) {
data[property] = options.replaceValue;
continue;
}
}
}
}

if (options.matcher !== undefined) {
data[property] = data[property].replaceAll(
options.matcher,
options.replaceString,
);
// deno-lint-ignore no-explicit-any
function innerRedact(data: any, options: InnerRedactOptions) {
if (typeof data !== "object" || data === null) {
throw new TypeError("Can only redact from objects or arrays");
}

if (data[property] === options.replaceString) {
data[property] = options.replaceValue;
}
const properties = Object.getOwnPropertyNames(data);
const symbols = Object.getOwnPropertySymbols(data);

for (const marker of options.markers) {
if (symbols.includes(marker)) {
for (const property of properties) {
try {
delete data[property];
} catch { /* ignore */ }
}
for (const symbol of symbols) {
try {
delete data[symbol];
} catch { /* ignore */ }
}

return options.replaceValue;
}
}

for (const symbol of symbols) {
if (options.symbols.includes(symbol)) {
Object.defineProperty(data, symbol, {
get: () => options.replaceValue,
});
for (const property of properties) {
if (options.properties.includes(property)) {
data[property] = options.replaceValue;
}
redactProperty(data, property, options);
}

if (options.literals.includes(data[symbol])) {
for (const symbol of symbols) {
if (options.symbols.includes(symbol)) {
data[symbol] = options.replaceValue;
continue;
}

if (typeof data[symbol] === "object") {
innerRedact(data[symbol], options);
}

if (typeof data[symbol] === "string") {
if (options.tester?.test(data[symbol])) {
data[symbol] = options.replaceValue;
continue;
}

if (options.matcher !== undefined) {
data[symbol] = data[symbol].replaceAll(
options.matcher,
options.replaceString,
);

if (data[symbol] === options.replaceString) {
data[symbol] = options.replaceValue;
}
}
}
redactProperty(data, symbol, options);
}

return data;
}

/**
* The default value to replace redacted data with.
*/
export const redacted = Symbol.for("redacted");

/**
* A marker symbol to indicate that the data is secret and should be redacted
* when encountered.
*/
export const secret = Symbol.for("secret");

function prepareTester(values?: unknown[]): RegExp | undefined {
if (values === undefined) return undefined;

Expand Down Expand Up @@ -135,10 +161,13 @@ function prepareMatcher(values?: unknown[]): RegExp | undefined {
return new RegExp(matchers.map((value) => `(${value})`).join("|"), "g");
}

function prepareOptions(options: RedactOptions): PreparedRedactOptions {
function prepareOptions<T>(options: RedactOptions<T> = {}): InnerRedactOptions {
const replaceValue = options.replace ?? redacted;

return {
markers: typeof options.markers === "symbol"
? [options.markers]
: options.markers ?? [secret],
properties:
options.properties?.filter((property): property is string | number =>
typeof property === "string" || typeof property === "number"
Expand All @@ -159,16 +188,32 @@ function prepareOptions(options: RedactOptions): PreparedRedactOptions {
};
}

export function redact(data: any, options: RedactOptions) {
innerRedact(data, prepareOptions(options));
/**
* Redacts the given data based on the provided options.
*
* This function mutates the {@link data} and returns a reference or the the
* same element or the redacted marker, indicating that the data was redacted.
*/
export function redact<T, R>(
data: T,
options?: RedactOptions<R>,
): T | R {
return innerRedact(data, prepareOptions(options));
}

export class RedactStream extends TransformStream<Log> {
constructor(options: RedactOptions) {
// deno-lint-ignore no-explicit-any
export class RedactStream<T = typeof redacted>
extends TransformStream<Log, Log & { data: any[] | T }> {
constructor(options?: RedactOptions<T>) {
options ??= {};
options.replace ??= redacted as T;
super({
transform(log, controller) {
redact(log.data, options);
controller.enqueue(log);
// deno-lint-ignore no-explicit-any
(log.data as any[] | T) = redact(log.data, options);
if (log.data === options?.replace) {
controller.enqueue(log);
}
},
});
}
Expand Down

0 comments on commit 915baed

Please sign in to comment.