diff --git a/.eslintrc.js b/.eslintrc.js index 34dca51..34f8286 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,5 +24,6 @@ module.exports = { "@typescript-eslint/no-unsafe-return": 0, "@typescript-eslint/no-unsafe-call": 0, "@typescript-eslint/no-unsafe-member-access": 0, + "@typescript-eslint/no-explicit-any": 0, }, } diff --git a/src/PIITry.ts b/src/PIITry.ts new file mode 100644 index 0000000..51f5ade --- /dev/null +++ b/src/PIITry.ts @@ -0,0 +1,9 @@ +import PII from "./pii" + +export default async function PIITry(fn: () => S | Promise): Promise { + try { + return fn() + } catch (e) { + throw PII(e, "REDACTED_ERROR") + } +} diff --git a/src/__tests__/constructor.ts b/src/__tests__/constructor.ts index 25dfb9f..fdee848 100644 --- a/src/__tests__/constructor.ts +++ b/src/__tests__/constructor.ts @@ -1,4 +1,5 @@ -import { PII, unwrap } from "../index" +import PII from "../pii" +import unwrap from "../unwrap" describe("PII", () => { it("should not leak into toString", () => { diff --git a/src/__tests__/containsPII.spec.ts b/src/__tests__/containsPII.spec.ts index 7c80593..359b25e 100644 --- a/src/__tests__/containsPII.spec.ts +++ b/src/__tests__/containsPII.spec.ts @@ -1,4 +1,5 @@ -import { PII, containsPII } from "../index" +import PII from "../pii" +import containsPII from "../containsPII" describe("containsPII", () => { it("should not find PII", () => { @@ -20,4 +21,8 @@ describe("containsPII", () => { expect(containsPII([PII("test")])).toBeTruthy() expect(containsPII({ test: PII(1) })).toBeTruthy() }) + + it("should return true after max depth", () => { + expect(containsPII({ test: [{ hello: "world" }] }, 2)).toBeTruthy() + }) }) diff --git a/src/__tests__/detect.spec.ts b/src/__tests__/detect.spec.ts index 5916b32..b7e464b 100644 --- a/src/__tests__/detect.spec.ts +++ b/src/__tests__/detect.spec.ts @@ -1,5 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { detect, isPII } from "../index" +import { isPII } from "../pii" +import detect from "../detect" +import unwrap from "../unwrap" const detector = (data: unknown) => Array.isArray(data) @@ -25,4 +26,10 @@ describe("detect", () => { const detectedArrays = detect(detector, { test: set }) expect(isPII(Array.from((detectedArrays as any).test)[0])).toBeTruthy() }) + + it("should return PII after max depth", () => { + const result: any = detect(() => false, { test: [{ hello: "world" }] }, 2) + + expect(unwrap(result.test[0])).toEqual({ hello: "world" }) + }) }) diff --git a/src/__tests__/fold.spec.ts b/src/__tests__/fold.spec.ts index d0c010f..60b711c 100644 --- a/src/__tests__/fold.spec.ts +++ b/src/__tests__/fold.spec.ts @@ -1,4 +1,6 @@ -import { PII, fold, unwrap } from "../index" +import PII from "../pii" +import fold from "../fold" +import unwrap from "../unwrap" describe("fold", () => { it("should fold multiple PII", () => { diff --git a/src/__tests__/map.spec.ts b/src/__tests__/map.spec.ts index 2bb0ee7..8b77e3b 100644 --- a/src/__tests__/map.spec.ts +++ b/src/__tests__/map.spec.ts @@ -1,4 +1,6 @@ -import { PII, map, unwrap } from "../index" +import PII from "../pii" +import map from "../map" +import unwrap from "../unwrap" describe("map", () => { it("should map inside PII", () => { diff --git a/src/__tests__/redact.spec.ts b/src/__tests__/redact.spec.ts index 4af8c41..a560f89 100644 --- a/src/__tests__/redact.spec.ts +++ b/src/__tests__/redact.spec.ts @@ -1,4 +1,5 @@ -import { PII, redact } from "../index" +import PII from "../pii" +import redact from "../redact" const REDACTED = "REDACTED" const redactor = () => REDACTED @@ -62,4 +63,10 @@ describe("redact", () => { two: REDACTED, }) }) + + it("should return PII after max depth", () => { + expect(redact(redactor, { test: [{ hello: "world" }] }, 2)).toEqual({ + test: [REDACTED], + }) + }) }) diff --git a/src/__tests__/tap.spec.ts b/src/__tests__/tap.spec.ts index ac3dc55..5c64f31 100644 --- a/src/__tests__/tap.spec.ts +++ b/src/__tests__/tap.spec.ts @@ -1,4 +1,6 @@ -import { PII, tap, unwrap } from "../index" +import PII from "../pii" +import tap from "../tap" +import unwrap from "../unwrap" describe("tap", () => { it("should tap inside PII", () => { diff --git a/src/__tests__/test.spec.ts b/src/__tests__/test.spec.ts index c7f5e6d..3505ba0 100644 --- a/src/__tests__/test.spec.ts +++ b/src/__tests__/test.spec.ts @@ -1,4 +1,5 @@ -import { PII, test } from "../index" +import PII from "../pii" +import test from "../test" describe("test", () => { it("should test predicate against PII", () => { diff --git a/src/__tests__/try.spec.ts b/src/__tests__/try.spec.ts index 60ad884..ef3dcf3 100644 --- a/src/__tests__/try.spec.ts +++ b/src/__tests__/try.spec.ts @@ -1,4 +1,5 @@ -import { PIITry, unwrap } from "../index" +import PIITry from "../PIITry" +import unwrap from "../unwrap" describe("PIITry", () => { it("should wrap results in PII", async () => { diff --git a/src/__tests__/unwrap.spec.ts b/src/__tests__/unwrap.spec.ts index b378f53..d8ec404 100644 --- a/src/__tests__/unwrap.spec.ts +++ b/src/__tests__/unwrap.spec.ts @@ -1,4 +1,5 @@ -import { PII, unwrap } from "../index" +import PII from "../pii" +import unwrap from "../unwrap" describe("unwrap", () => { it("upwraps a value", () => { diff --git a/src/__tests__/unwrapObject.spec.ts b/src/__tests__/unwrapObject.spec.ts index d980360..aeb269a 100644 --- a/src/__tests__/unwrapObject.spec.ts +++ b/src/__tests__/unwrapObject.spec.ts @@ -1,4 +1,5 @@ -import { PII, unwrapObject } from "../index" +import PII from "../pii" +import unwrapObject from "../unwrapObject" describe("unwrapObject", () => { it("should remove all wrappers", () => { @@ -59,4 +60,10 @@ describe("unwrapObject", () => { two: 2, }) }) + + it("should return null after max depth", () => { + expect(unwrapObject({ test: [{ hello: "world" }] }, 2)).toEqual({ + test: [null], + }) + }) }) diff --git a/src/__tests__/zipWith.spec.ts b/src/__tests__/zipWith.spec.ts index 862cbe9..44a53d9 100644 --- a/src/__tests__/zipWith.spec.ts +++ b/src/__tests__/zipWith.spec.ts @@ -1,4 +1,8 @@ -import { PII, zip2With, zip3With, zip4With, unwrap } from "../index" +import PII from "../pii" +import zip2With from "../zip2With" +import zip3With from "../zip3With" +import zip4With from "../zip4With" +import unwrap from "../unwrap" describe("zipWith", () => { it("should zipWith two different types of PII", () => { diff --git a/src/containsPII.ts b/src/containsPII.ts new file mode 100644 index 0000000..e72ac48 --- /dev/null +++ b/src/containsPII.ts @@ -0,0 +1,20 @@ +import { isPII } from "./pii" +import visit from "./visit" + +const containsPII = (input: unknown, maxDepth = Infinity): boolean => + maxDepth === 0 || isPII(input) + ? true + : visit(input, { + record: o => Object.values(o).some(i => containsPII(i, maxDepth - 1)), + map: m => + Array.from(m).some( + ([k, v]) => + containsPII(k, maxDepth - 1) || containsPII(v, maxDepth - 1), + ), + array: a => a.some(i => containsPII(i, maxDepth - 1)), + set: s => Array.from(s).some(i => containsPII(i, maxDepth - 1)), + primitive: p => isPII(p), + object: p => isPII(p), + }) + +export default containsPII diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..c9c3546 --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,33 @@ +import PII, { isPII } from "./pii" +import visit from "./visit" + +const detect = ( + detector: (data: unknown) => boolean, + input: unknown, + maxDepth = Infinity, +): unknown => + isPII(input) + ? input + : detector(input) || maxDepth === 0 + ? PII(input) + : visit(input, { + record: r => + Object.keys(r).reduce((sum, key) => { + sum[key] = detect(detector, r[key], maxDepth - 1) + return sum + }, {} as Record), + map: m => + new Map( + Array.from(m).map(([k, v]) => [ + detect(detector, k, maxDepth - 1), + detect(detector, v, maxDepth - 1), + ]), + ), + array: a => a.map(x => detect(detector, x, maxDepth - 1)), + set: s => + new Set(Array.from(s).map(x => detect(detector, x, maxDepth - 1))), + primitive: p => p, + object: o => o, + }) + +export default detect diff --git a/src/fold.ts b/src/fold.ts new file mode 100644 index 0000000..6eb5fec --- /dev/null +++ b/src/fold.ts @@ -0,0 +1,13 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +export default ( + fn: ( + previousValue: B, + currentValue: A, + currentIndex: number, + array: A[], + ) => B, + initial: B, + a: Array | A>, +): PII => PII(a.map(unwrap).reduce(fn, initial)) diff --git a/src/index.ts b/src/index.ts index 3804d7c..ebe214c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,14 @@ -export * from "./pii" -export * from "./result" +export { default as containsPII } from "./containsPII" +export { default as detect } from "./detect" +export { default as fold } from "./fold" +export { default as map } from "./map" +export { default as PII, isPII } from "./pii" +export { default as PIITry } from "./PIITry" +export { default as redact } from "./redact" +export { default as tap } from "./tap" +export { default as test } from "./test" +export { default as unwrap } from "./unwrap" +export { default as unwrapObject } from "./unwrapObject" +export { default as zip2With } from "./zip2With" +export { default as zip3With } from "./zip3With" +export { default as zip4With } from "./zip4With" diff --git a/src/isObject.ts b/src/isObject.ts new file mode 100644 index 0000000..62397be --- /dev/null +++ b/src/isObject.ts @@ -0,0 +1,5 @@ +// Function, regex, object, Number, String, etc +export default (value: unknown): boolean => { + const type = typeof value + return value != null && (type == "object" || type == "function") +} diff --git a/src/isRecord.ts b/src/isRecord.ts new file mode 100644 index 0000000..0a5a0b1 --- /dev/null +++ b/src/isRecord.ts @@ -0,0 +1,5 @@ +const proto = Object.prototype +const gpo = Object.getPrototypeOf + +export default (obj: unknown): obj is Record => + obj === null || typeof obj !== "object" ? false : gpo(obj) === proto diff --git a/src/map.ts b/src/map.ts new file mode 100644 index 0000000..5ef4725 --- /dev/null +++ b/src/map.ts @@ -0,0 +1,13 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +function map(fn: (item: T) => T2, item: PII): PII +function map(fn: (item: T) => T2, item: T): Exclude> +function map( + fn: (item: T) => T2, + item: PII | T, +): PII | Exclude> { + return PII(fn(unwrap(item))) +} + +export default map diff --git a/src/pii.ts b/src/pii.ts index f77bd0e..b702470 100644 --- a/src/pii.ts +++ b/src/pii.ts @@ -1,16 +1,16 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import isRecord from "./isRecord" + // Class which wraps PII and keeps logging from accessing it. // eslint-disable-next-line @typescript-eslint/no-unused-vars -export interface PII { +interface PII { toString: () => string toJSON: () => string } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isPII = (val: any): val is PII => +export const isPII = (val: unknown): val is PII => isRecord(val) && val.__brand === "PII" -export const PII = (val: T, msg = "REDACTED"): PII => +const PII = (val: T, msg = "REDACTED"): PII => isPII(val) ? val : ({ @@ -20,193 +20,4 @@ export const PII = (val: T, msg = "REDACTED"): PII => toJSON: () => `PII<${msg}>`, } as PII) -export function unwrap(item: PII): Exclude> -export function unwrap(item: T): Exclude> -export function unwrap(item: T | PII): Exclude> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return isPII(item) - ? (item as any)[ - "__fire_me_if_you_see_me_accessing_this_property_outside_pii_ts" - ] - : item -} - -export function map(fn: (item: T) => T2, item: PII): PII -export function map(fn: (item: T) => T2, item: T): Exclude> -export function map( - fn: (item: T) => T2, - item: PII | T, -): PII | Exclude> { - return PII(fn(unwrap(item))) -} - -export function tap(fn: (item: T) => void, item: PII): PII -export function tap(fn: (item: T) => void, item: T): T -export function tap(fn: (item: T) => void, item: PII | T): PII | T { - fn(unwrap(item)) - - return item -} - -export function test(fn: (item: T) => boolean, item: PII): boolean -export function test(fn: (item: T) => boolean, item: T): boolean -export function test(fn: (item: T) => boolean, item: PII | T): boolean { - return fn(unwrap(item)) -} - -export function fold( - fn: ( - previousValue: B, - currentValue: A, - currentIndex: number, - array: A[], - ) => B, - initial: B, - a: Array | A>, -): PII { - return PII(a.map(unwrap).reduce(fn, initial)) -} - -export const zip2With = ( - fn: (a: A, b: B) => C, - a: PII | A, - b: PII | B, -): PII => PII(fn(unwrap(a), unwrap(b))) - -export const zip3With = ( - fn: (a: A, b: B, c: C) => D, - a: PII | A, - b: PII | B, - c: PII | C, -): PII => PII(fn(unwrap(a), unwrap(b), unwrap(c))) - -export const zip4With = ( - fn: (a: A, b: B, c: C, d: D) => E, - a: PII | A, - b: PII | B, - c: PII | C, - d: PII | D, -): PII => PII(fn(unwrap(a), unwrap(b), unwrap(c), unwrap(d))) - -const proto = Object.prototype -const gpo = Object.getPrototypeOf - -const isRecord = (obj: unknown): obj is Record => - obj === null || typeof obj !== "object" ? false : gpo(obj) === proto - -// Function, regex, object, Number, String, etc -const isObject = (value: unknown): boolean => { - const type = typeof value - return value != null && (type == "object" || type == "function") -} - -export const visitPII = ( - input: A, - visitors: { - record: (value: Record) => T - map: (value: Map) => T - set: (value: Set) => T - object: (value: unknown) => T - array: (value: Array) => T - primitive: (value: A) => T - }, -): T => { - if (isRecord(input)) { - return visitors.record(input) - } - - if (Array.isArray(input)) { - return visitors.array(input) - } - - if (input instanceof Map) { - return visitors.map(input) - } - - if (input instanceof Set) { - return visitors.set(input) - } - - if (isObject(input)) { - return visitors.object(input) - } - - return visitors.primitive(input) -} - -export const containsPII = (input: unknown): boolean => - isPII(input) - ? true - : visitPII(input, { - record: o => Object.values(o).some(containsPII), - map: m => - Array.from(m).some(([k, v]) => containsPII(k) || containsPII(v)), - array: a => a.some(containsPII), - set: s => Array.from(s).some(containsPII), - primitive: p => isPII(p), - object: p => isPII(p), - }) - -export const unwrapObject = (input: unknown): unknown => - visitPII(isPII(input) ? unwrap(input) : input, { - record: o => - Object.keys(o).reduce((sum, key) => { - sum[key] = unwrapObject(o[key]) - return sum - }, {} as Record), - map: m => - new Map( - Array.from(m).map(([k, v]) => [unwrapObject(k), unwrapObject(v)]), - ), - array: a => a.map(unwrapObject), - set: s => new Set(Array.from(s).map(unwrapObject)), - primitive: p => p, - object: p => p, - }) - -export const redact = (redactor: (data: any) => any, input: unknown): unknown => - visitPII(isPII(input) ? redactor(input) : input, { - record: o => - Object.keys(o).reduce((sum, key) => { - sum[key] = redact(redactor, o[key]) - return sum - }, {} as Record), - map: m => - new Map( - Array.from(m).map(([k, v]) => [ - redact(redactor, k), - redact(redactor, v), - ]), - ), - array: a => a.map(x => redact(redactor, x)), - set: s => new Set(Array.from(s).map(x => redact(redactor, x))), - primitive: p => p, - object: p => p, - }) - -export const detect = ( - detector: (data: any) => boolean, - input: unknown, -): unknown => - isPII(input) - ? input - : detector(input) - ? PII(input) - : visitPII(input, { - record: o => - Object.keys(o).reduce((sum, key) => { - sum[key] = detect(detector, o[key]) - return sum - }, {} as Record), - map: m => - new Map( - Array.from(m).map(([k, v]) => [ - detect(detector, k), - detect(detector, v), - ]), - ), - array: a => a.map(x => detect(detector, x)), - set: s => new Set(Array.from(s).map(x => detect(detector, x))), - primitive: p => p, - object: p => p, - }) +export default PII diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..2d24142 --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,31 @@ +import { isPII } from "./pii" +import visit from "./visit" + +const redact = ( + redactor: (data: any) => any, + input: unknown, + maxDepth = Infinity, +): unknown => + maxDepth === 0 + ? redactor(input) + : visit(isPII(input) ? redactor(input) : input, { + record: r => + Object.keys(r).reduce((sum, key) => { + sum[key] = redact(redactor, r[key], maxDepth - 1) + return sum + }, {} as Record), + map: m => + new Map( + Array.from(m).map(([k, v]) => [ + redact(redactor, k, maxDepth - 1), + redact(redactor, v, maxDepth - 1), + ]), + ), + array: a => a.map(x => redact(redactor, x, maxDepth - 1)), + set: s => + new Set(Array.from(s).map(x => redact(redactor, x, maxDepth - 1))), + primitive: p => p, + object: o => o, + }) + +export default redact diff --git a/src/result.ts b/src/result.ts deleted file mode 100644 index 81e37d2..0000000 --- a/src/result.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PII } from "./pii" - -export const identity = (x: X): X => x - -export async function PIITry(fn: () => S | Promise): Promise { - try { - return fn() - } catch (e) { - throw PII(e, "REDACTED_ERROR") - } -} diff --git a/src/tap.ts b/src/tap.ts new file mode 100644 index 0000000..aec49d4 --- /dev/null +++ b/src/tap.ts @@ -0,0 +1,12 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +function tap(fn: (item: T) => void, item: PII): PII +function tap(fn: (item: T) => void, item: T): T +function tap(fn: (item: T) => void, item: PII | T): PII | T { + fn(unwrap(item)) + + return item +} + +export default tap diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..bbaa06c --- /dev/null +++ b/src/test.ts @@ -0,0 +1,10 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +function test(fn: (item: T) => boolean, item: PII): boolean +function test(fn: (item: T) => boolean, item: T): boolean +function test(fn: (item: T) => boolean, item: PII | T): boolean { + return fn(unwrap(item)) +} + +export default test diff --git a/src/unwrap.ts b/src/unwrap.ts new file mode 100644 index 0000000..030197e --- /dev/null +++ b/src/unwrap.ts @@ -0,0 +1,13 @@ +import PII, { isPII } from "./pii" + +function unwrap(item: PII): Exclude> +function unwrap(item: T): Exclude> +function unwrap(item: T | PII): Exclude> { + return isPII(item) + ? (item as any)[ + "__fire_me_if_you_see_me_accessing_this_property_outside_pii_ts" + ] + : item +} + +export default unwrap diff --git a/src/unwrapObject.ts b/src/unwrapObject.ts new file mode 100644 index 0000000..821abfe --- /dev/null +++ b/src/unwrapObject.ts @@ -0,0 +1,28 @@ +import { isPII } from "./pii" +import visit from "./visit" +import unwrap from "./unwrap" + +const unwrapObject = (input: unknown, maxDepth = Infinity): unknown => + maxDepth === 0 + ? null + : visit(isPII(input) ? unwrap(input) : input, { + record: r => + Object.keys(r).reduce((sum, key) => { + sum[key] = unwrapObject(r[key], maxDepth - 1) + return sum + }, {} as Record), + map: m => + new Map( + Array.from(m).map(([k, v]) => [ + unwrapObject(k, maxDepth - 1), + unwrapObject(v, maxDepth - 1), + ]), + ), + array: a => a.map(i => unwrapObject(i, maxDepth - 1)), + set: s => + new Set(Array.from(s).map(i => unwrapObject(i, maxDepth - 1))), + primitive: p => p, + object: o => o, + }) + +export default unwrapObject diff --git a/src/visit.ts b/src/visit.ts new file mode 100644 index 0000000..f5f4742 --- /dev/null +++ b/src/visit.ts @@ -0,0 +1,36 @@ +import isRecord from "./isRecord" +import isObject from "./isObject" + +export default ( + input: A, + visitors: { + record: (value: Record) => T + map: (value: Map) => T + set: (value: Set) => T + object: (value: unknown) => T + array: (value: Array) => T + primitive: (value: A) => T + }, +): T => { + if (isRecord(input)) { + return visitors.record(input) + } + + if (Array.isArray(input)) { + return visitors.array(input) + } + + if (input instanceof Map) { + return visitors.map(input) + } + + if (input instanceof Set) { + return visitors.set(input) + } + + if (isObject(input)) { + return visitors.object(input) + } + + return visitors.primitive(input) +} diff --git a/src/zip2With.ts b/src/zip2With.ts new file mode 100644 index 0000000..7816fc6 --- /dev/null +++ b/src/zip2With.ts @@ -0,0 +1,8 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +export default ( + fn: (a: A, b: B) => C, + a: PII | A, + b: PII | B, +): PII => PII(fn(unwrap(a), unwrap(b))) diff --git a/src/zip3With.ts b/src/zip3With.ts new file mode 100644 index 0000000..d1cca96 --- /dev/null +++ b/src/zip3With.ts @@ -0,0 +1,9 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +export default ( + fn: (a: A, b: B, c: C) => D, + a: PII | A, + b: PII | B, + c: PII | C, +): PII => PII(fn(unwrap(a), unwrap(b), unwrap(c))) diff --git a/src/zip4With.ts b/src/zip4With.ts new file mode 100644 index 0000000..c57d286 --- /dev/null +++ b/src/zip4With.ts @@ -0,0 +1,10 @@ +import PII from "./pii" +import unwrap from "./unwrap" + +export default ( + fn: (a: A, b: B, c: C, d: D) => E, + a: PII | A, + b: PII | B, + c: PII | C, + d: PII | D, +): PII => PII(fn(unwrap(a), unwrap(b), unwrap(c), unwrap(d)))