purgo
Version:
Zero-config PHI-scrubber for browser and Node.js
158 lines (157 loc) • 4.39 kB
JavaScript
// 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();
export {
BUILT_IN_PATTERNS,
initRedactionEngine,
redact
};