UNPKG

purgo

Version:

Zero-config PHI-scrubber for browser and Node.js

352 lines (347 loc) 11.1 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { purgo: () => purgo, redact: () => redact }); module.exports = __toCommonJS(src_exports); // src/redact.ts var BUILT_IN_PATTERNS = { // Email addresses email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Phone numbers (various formats) phone: /(\+\d{1,2}\s?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}/g, // Social Security Numbers ssn: /\b\d{3}[-]?\d{2}[-]?\d{4}\b/g, // Medical Record Numbers (generic pattern, can be customized) mrn: /\b\d{6,10}\b/g, // ICD-10 codes icd10: /\b[A-Z]\d{2}(\.[A-Z0-9]{1,4})?\b/g }; var DEFAULT_CENSOR = () => "***"; var globalConfig = { patterns: [], censor: DEFAULT_CENSOR, hashMode: false }; function initRedactionEngine(options = {}) { const patterns = options.patterns || ["email", "phone", "ssn", "mrn", "icd10"]; const regexPatterns = patterns.map((pattern) => { if (typeof pattern === "string") { return BUILT_IN_PATTERNS[pattern] || new RegExp(pattern, "g"); } return pattern; }); globalConfig = { patterns: regexPatterns, censor: options.censor || DEFAULT_CENSOR, hashMode: options.hashMode || false }; } function redactString(value) { if (typeof value !== "string") return value; let result = value; try { for (const pattern of globalConfig.patterns) { const patternCopy = new RegExp(pattern.source, pattern.flags); result = result.replace(patternCopy, (match) => { if (globalConfig.hashMode) { const hash = match.split("").reduce((a, b) => { const hashCode = a.charCodeAt(0) ^ b.charCodeAt(0); return String.fromCharCode(hashCode); }, "\0"); return `[HASH:${hash}]`; } return globalConfig.censor(match); }); } } catch (e) { console.error("Purgo: Error during string redaction, returning original value", e); return value; } return result; } function isDOMNode(obj) { return typeof Node === "object" ? obj instanceof Node : obj && typeof obj === "object" && typeof obj.nodeType === "number"; } function redact(value, seen = /* @__PURE__ */ new WeakSet()) { if (value === null || value === void 0) return value; if (typeof value !== "object") { return typeof value === "string" ? redactString(value) : value; } if (isDOMNode(value)) return value; if (seen.has(value)) return value; seen.add(value); if (Array.isArray(value)) { let hasCircularRefs2 = false; const length = value.length; const result2 = new Array(length); for (let i = 0; i < length; i++) { const item = value[i]; if (item === value) { result2[i] = null; } else { result2[i] = redact(item, seen); } } if (hasCircularRefs2) { for (let i = 0; i < length; i++) { if (value[i] === value) { result2[i] = result2; } } } return result2; } let hasPrimitiveOnly = true; let hasCircularRefs = false; let hasPHI = false; for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { const propValue = value[key]; if (propValue === value) { hasCircularRefs = true; hasPrimitiveOnly = false; break; } if (propValue !== null && typeof propValue === "object") { hasPrimitiveOnly = false; break; } if (typeof propValue === "string" && containsPHI(propValue)) { hasPHI = true; break; } } } if (hasPrimitiveOnly && !hasPHI) { return value; } const result = {}; const circularKeys = []; for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { const propValue = value[key]; if (propValue === value) { hasCircularRefs = true; circularKeys.push(key); result[key] = null; } else { result[key] = redact(propValue, seen); } } } if (hasCircularRefs) { for (const key of circularKeys) { result[key] = result; } } return result; } function containsPHI(value) { try { for (const pattern of globalConfig.patterns) { const patternCopy = new RegExp(pattern.source, pattern.flags); if (patternCopy.test(value)) { return true; } } return false; } catch (e) { console.error("Purgo: Error checking for PHI, assuming it might contain PHI", e); return true; } } initRedactionEngine(); // src/browser.ts var originalConsole = {}; var originalXHROpen = typeof XMLHttpRequest !== "undefined" ? XMLHttpRequest.prototype.open : void 0; var originalXHRSend = typeof XMLHttpRequest !== "undefined" ? XMLHttpRequest.prototype.send : void 0; function patchConsole() { if (typeof console === "undefined") return; const methodsToPatch = [ "log", "info", "warn", "error", "debug", "trace" ]; const isNextJs = typeof window !== "undefined" && window.__NEXT_DATA__ !== void 0; if (isNextJs) { console.info("Purgo: Detected Next.js environment, using compatible patching mode"); } methodsToPatch.forEach((method) => { if (typeof console[method] !== "function") return; originalConsole[method] = console[method]; console[method] = function(...args) { try { if (isNextJs && args.length > 0 && typeof args[0] === "string" && (args[0].includes("[HMR]") || args[0].includes("[Fast Refresh]") || args[0].includes("[webpack]"))) { return originalConsole[method].apply(console, args); } const redactedArgs = args.map((arg) => redact(arg)); return originalConsole[method].apply(console, redactedArgs); } catch (e) { console.error("Purgo: Error during redaction, falling back to original behavior", e); return originalConsole[method].apply(console, args); } }; }); } function patchFetch() { if (typeof window === "undefined" || !window.fetch) return; const isNextJs = typeof window !== "undefined" && window.__NEXT_DATA__ !== void 0; const origFetch = window.fetch; window.fetch = function(input, init) { try { if (isNextJs) { const inputUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; if (inputUrl.includes("/_next/") || inputUrl.includes("/__nextjs_original-stack-frame") || inputUrl.includes("/__webpack_hmr") || inputUrl.includes("/_error")) { return origFetch.call(window, input, init); } } let redactedInit; if (init) { redactedInit = { ...init }; if (init.body) { if (typeof init.body === "string") { redactedInit.body = redact(init.body); } else if (init.body instanceof FormData || init.body instanceof URLSearchParams || init.body instanceof Blob || init.body instanceof ArrayBuffer) { redactedInit.body = init.body; } else { try { const bodyStr = JSON.stringify(init.body); const redactedBodyStr = redact(bodyStr); redactedInit.body = JSON.parse(redactedBodyStr); } catch (e) { redactedInit.body = init.body; } } } if (init.headers) { if (init.headers instanceof Headers) { redactedInit.headers = new Headers(); init.headers.forEach((value, key) => { redactedInit.headers.append(key, redact(value)); }); } else if (Array.isArray(init.headers)) { redactedInit.headers = init.headers.map((entry) => { if (entry.length >= 2) { const [key, value] = entry; return [key, typeof value === "string" ? redact(value) : value]; } return entry; }); } else { redactedInit.headers = {}; for (const key in init.headers) { redactedInit.headers[key] = redact(init.headers[key]); } } } } let redactedInput = input; if (typeof input === "string") { try { const url = new URL(input, window.location.origin); url.search = redact(url.search); redactedInput = url.toString(); } catch (e) { redactedInput = input; } } return origFetch.call(window, redactedInput, redactedInit); } catch (e) { console.error("Purgo: Error during fetch redaction, falling back to original behavior", e); return origFetch.call(window, input, init); } }; } function patchXHR() { if (typeof XMLHttpRequest === "undefined") return; XMLHttpRequest.prototype.open = function(method, url, async = true, username, password) { let redactedUrl = url; if (typeof url === "string") { try { const urlObj = new URL(url, window.location.origin); urlObj.search = redact(urlObj.search); redactedUrl = urlObj.toString(); } catch (e) { redactedUrl = url; } } return originalXHROpen.call( this, method, redactedUrl, async, username, password ); }; XMLHttpRequest.prototype.send = function(body) { let redactedBody = body; if (body) { if (typeof body === "string") { redactedBody = redact(body); } else if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob || body instanceof ArrayBuffer) { redactedBody = body; } else { try { const bodyStr = JSON.stringify(body); const redactedBodyStr = redact(bodyStr); redactedBody = JSON.parse(redactedBodyStr); } catch (e) { redactedBody = body; } } } return originalXHRSend.call(this, redactedBody); }; } function initBrowserPatches(options = {}) { const targets = options.targets || ["console", "fetch", "xhr"]; if (targets.includes("console")) { patchConsole(); } if (typeof window !== "undefined") { if (targets.includes("fetch")) { patchFetch(); } if (targets.includes("xhr")) { patchXHR(); } } } // src/index.ts function purgo(options = {}) { initRedactionEngine(options); initBrowserPatches(options); } purgo();