purgo
Version:
Zero-config PHI-scrubber for browser and Node.js
354 lines (349 loc) • 11.8 kB
JavaScript
;
var purgo = (() => {
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
});
// 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();
return __toCommonJS(src_exports);
})();