diff --git a/.gitignore b/.gitignore index 3e0a172..eb59d88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules sftp-config.json .vscode/launch.json .env +combined.log diff --git a/package.json b/package.json index 2768294..cedfc3b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Tracker Utils", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest" }, "repository": { "type": "git", @@ -32,5 +32,8 @@ "redis": "3.1.1", "ulidx": "^2.4.1", "winston": "^3.8.2" + }, + "devDependencies": { + "jest": "^30.2.0" } } diff --git a/src/logger/logger.js b/src/logger/logger.js index ffbe32a..37dfb4c 100644 --- a/src/logger/logger.js +++ b/src/logger/logger.js @@ -1,4 +1,5 @@ const winston = require("winston"); +const { maskData } = require("./mask"); const logger = (() => { let LOGGER_ENABLED = true; @@ -37,7 +38,13 @@ const logger = (() => { }; const getLogstring = (info) => { - return `[REQ_ID: ${requestId}][USER: ${user}][USER_ID: ${userId}][LEVEL:${info.level}][MSG:${info.message}]`; + const safeMessage = maskData(info.message); + const printableMessage = + typeof safeMessage === "object" + ? JSON.stringify(safeMessage) + : safeMessage; + + return `[REQ_ID: ${requestId}][USER: ${user}][USER_ID: ${userId}][LEVEL:${info.level}][MSG:${printableMessage}]`; }; const info = (msg) => { diff --git a/src/logger/mask.js b/src/logger/mask.js new file mode 100644 index 0000000..413c49a --- /dev/null +++ b/src/logger/mask.js @@ -0,0 +1,153 @@ +// Enable/Disable masking via env (default: enabled) +const ENABLE_MASKING = process.env.MASK_LOGS !== "false"; + +// Regex patterns for detecting emails and phone numbers +const REGEX_PATTERNS = [ + { + regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + replacer: (match) => maskEmail(match) + }, + { + regex: /\+?\d[\d\s\-]{8,}\d/g, + replacer: (match) => maskPhone(match) + } +]; + +// Generic mask used for structured fields like address +const MASK = "********"; + +// Keys that contain IDs — not PII, skip regex masking to avoid false positives +const SKIP_MASK_KEYS = new Set(['_id', 'id', 'org_id', 'user_id', 'campaign_id', 'publisher_id']); + +// Masks phone number and exposes last 4 digits +const maskPhone = (phone) => { + const digits = phone.replace(/\D/g, ""); + + if (digits.length <= 3) return "*".repeat(digits.length); + + const last4 = digits.slice(-4); + return "*".repeat(Math.max(digits.length - 4, 0)) + last4; +}; + +// Masks email while partially exposing user and domain +const maskEmail = (email) => { + const [user, domain] = email.split("@"); + if (!domain) return "***"; + + const maskedUser = + user.length <= 3 + ? "*".repeat(user.length) + : ( + user[0] + + "*".repeat(user.length - 3) + + user.slice(-2) + ); + + const domainParts = domain.split("."); + const mainDomain = domainParts[0] || ""; + + const maskedDomain = + mainDomain.length <= 3 + ? "***" + : ( + "*".repeat(mainDomain.length - 2) + + mainDomain.slice(-2) + ); + + const maskedTld = "***"; + + return `${maskedUser}@${maskedDomain}.${maskedTld}`; +}; + +// Masks name by showing the first and last two characters +const maskName = (name) => { + if (!name || typeof name !== "string") return name; + + return name + .split(" ") + .map(part => { + if (part.length <= 3) return "*".repeat(part.length); + + return ( + part[0] + + "*".repeat(part.length - 3) + + part.slice(-2) + ); + }) + .join(" "); +}; + +// Applies regex-based masking on free-form strings +const applyRegexMasking = (str) => { + let masked = str; + + REGEX_PATTERNS.forEach(({ regex, replacer }) => { + masked = masked.replace(regex, replacer); + }); + + return masked; +}; + +// Recursively masks structured objects (name, region, nested fields) +const maskObject = (obj, seen = new WeakSet()) => { + if (!obj || typeof obj !== "object") return obj; + if (seen.has(obj)) return "[Circular]"; + seen.add(obj); + + const cloned = Array.isArray(obj) ? [...obj] : { ...obj }; + + Object.keys(cloned).forEach(key => { + if (SKIP_MASK_KEYS.has(key)) return; + + const value = cloned[key]; + + if ((key === "name" || key === "user") && typeof value === "string") { + cloned[key] = maskName(value); + return; + } + + if (key === "region" && value!==null && typeof value === "object") { + const maskedRegion = maskObject(value, seen); + cloned[key] = { + ...maskedRegion, + address: value.address ? MASK : value.address, + city: value.city ? MASK : value.city, + state: value.state ? MASK : value.state, + zipcode: value.zipcode ? MASK : value.zipcode + }; + return; + } + + if (typeof value === "string") { + cloned[key] = applyRegexMasking(value); + return; + } + + if (typeof value === "object") { + cloned[key] = maskObject(value, seen); + } + }); + + return cloned; +}; + +// Entry point for masking (handles string or object) +const maskData = (data) => { + + // Skip masking if disabled + if (!ENABLE_MASKING) return data; + + if (!data) return data; + + if (typeof data === "string") { + return applyRegexMasking(data); + } + + if (typeof data === "object") { + return maskObject(data); + } + + return data; +}; + +module.exports = { maskData };