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/README.md b/README.md index d1bd8fc..b0a3295 100644 --- a/README.md +++ b/README.md @@ -1 +1,16 @@ # tracker-utils +## Logger Masking Toggle + +Masking is enabled by default. + +To disable masking (Debug Mode): + +```bash +MASK_LOGS=false node examples/loggerTest.js +``` +To enable masking: + +```bash +MASK_LOGS=true node examples/loggerTest.js +``` +If MASK_LOGS is not set, masking remains enabled by default. diff --git a/examples/loggerTest.js b/examples/loggerTest.js new file mode 100644 index 0000000..38a53de --- /dev/null +++ b/examples/loggerTest.js @@ -0,0 +1,128 @@ +// Manual logger masking demo script (not used in production) +let { Logger } = require('../index'); + +Logger.init({ + service: "test-service", + enableConsoleLog: true +}); + +// EMAIL + PHONE TESTS +Logger.info("User email is test@gmail.com and phone is +919876543210"); +Logger.info("User email is test@gmail.com and phone is (+91) 9876543210"); +Logger.info("User phone is 98765 43210"); + +Logger.info("Contact test@gmail.com or admin@yahoo.com"); +Logger.info("Email vikrant+prod@gmail.com"); +Logger.info("Email user@mail.company.co.uk"); +Logger.info('{"email":"abc@gmail.com"}'); +Logger.info("ADMIN@COMPANY.COM"); +Logger.info("Email test@gmail"); +Logger.info("tom@gmail.com"); + +// PHONE VARIATIONS +Logger.info("Call 9876543210"); +Logger.info("Call +919876543210"); +Logger.info("Call +919876543210"); +Logger.info("Call 98765-43210"); +Logger.info("Phones 9876543210 and 9123456789"); +Logger.info("US number +14155552671"); +Logger.info("Call (+91) 9876543210"); +Logger.info("Call +256 1234567899") + +// MIXED STRING TEST +Logger.info("User abc@gmail.com called 9876543210"); + +// JSON STRING TEST +Logger.info('{"email":"abc@gmail.com","phone":"9876543210"}'); + +// OBJECT LOG TESTS +Logger.info({ + message: "Payment from user@gmail.com", + phone: "+919876543210" +}); + +Logger.info({ + email: "abc@gmail.com", + phone: "9876543210", + message: "Testing object log with some dummy value 876 and email is abc@gmail.com with phone number is 1234567890" +}); + +// NAME MASKING TESTS +Logger.info({ + name: "Vikrant", + email: "user@gmail.com", + phone: "9876543210" +}); + +Logger.info({ + name: "John Doe", + message: "User profile updated" +}); + +// REGION ADDRESS TESTS +Logger.info({ + name: "Alice Wonderland", + region: { + address: "B 63, Sector 70, Zone 2", + city: "Noida", + state: "Uttar Pradesh", + zipcode: "201301", + country: "IN" + } +}); + +Logger.info({ + region: { + address: "221B Baker Street, Zone 5", + city: "London", + state: "Greater London", + zipcode: "NW16XE" + }, + message: "Shipping details updated" +}); + +Logger.info({ + region: { + address: "Contact abc@gmail.com or call +919876543210", + city: "Mumbai", + state: "MH", + zipcode: "400001" + } +}); + +Logger.info({ + region: { + city: "Delhi", + state: "Delhi" + } +}); + +// DB-LIKE OBJECT TEST +Logger.info({ + display_id: 752, + name: "Test Advertiser", + email: "advtest123@test.com", + phone: "9876543210", + region: { + address: "B 63 Sector 70 Zone 2", + city: "Noida", + state: "UP", + zipcode: "201301", + country: "IN", + currency: "INR" + }, + status: "active" +}); + +Logger.info({ + display_id: 752, + name: "test", + email: "test@gmail.com", + phone: "9876543210", + status: "active" +}); + + +// SAFE TEXT TEST +Logger.info("Service started successfully"); +Logger.info("No sensitive data here"); 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/__tests__/logger.test.js b/src/__tests__/logger.test.js new file mode 100644 index 0000000..82ac8a3 --- /dev/null +++ b/src/__tests__/logger.test.js @@ -0,0 +1,163 @@ +// Import masking function +const { maskData } = require('../logger/mask'); + +describe("PII Masking Tests", () => { + + /* ---------------- EMAIL TESTS ---------------- */ + + // Should mask simple email + test("should mask simple email", () => { + const input = "test@gmail.com"; + const output = maskData(input); + expect(output).toContain("@"); + expect(output).not.toContain("test@gmail.com"); + }); + + // Should mask short email user <=3 + test("should mask short email user <=3", () => { + const input = "tom@gmail.com"; + const output = maskData(input); + expect(output).toMatch(/\*\*\*/); + }); + + // Should mask complex email + test("should mask complex email", () => { + const input = "vikrant.yadav@trackier.com"; + const output = maskData(input); + expect(output).not.toContain("vikrant.yadav"); + expect(output).not.toContain("trackier"); + }); + + // Should mask multiple emails + test("should mask multiple emails in string", () => { + const input = "Contact test@gmail.com or admin@yahoo.com"; + const output = maskData(input); + expect(output).not.toContain("test@gmail.com"); + expect(output).not.toContain("admin@yahoo.com"); + }); + + /* ---------------- PHONE TESTS ---------------- */ + + // Should expose last 4 digits + test("should mask phone and expose last 4 digits", () => { + const input = "9876543210"; + const output = maskData(input); + expect(output.endsWith("3210")).toBe(true); + }); + + // Should handle phone with country code + test("should mask phone with country code", () => { + const input = "+256 1234567899"; + const output = maskData(input); + expect(output.endsWith("7899")).toBe(true); + }); + + // Should not change phone <=3 digits (regex won’t match) + test("should not mask phone <=3 digits", () => { + const input = "123"; + const output = maskData(input); + expect(output).toBe("123"); + }); + + /* ---------------- NAME TESTS ---------------- */ + + // Should mask full name + test("should mask full name", () => { + const input = { name: "Vikrant Yadav" }; + const output = maskData(input); + expect(output.name).not.toBe("Vikrant Yadav"); + }); + + // Should fully mask short name + test("should mask short name fully", () => { + const input = { name: "Tom" }; + const output = maskData(input); + expect(output.name).toBe("***"); + }); + + /* ---------------- REGION TESTS ---------------- */ + + // Should mask region fields + test("should mask region fields", () => { + const input = { + region: { + address: "Sector 70", + city: "Noida", + state: "UP", + zipcode: "201301" + } + }; + + const output = maskData(input); + + expect(output.region.address).toBe("********"); + expect(output.region.city).toBe("********"); + expect(output.region.state).toBe("********"); + expect(output.region.zipcode).toBe("********"); + }); + + /* ---------------- OBJECT TESTS ---------------- */ + + // Should mask email and phone in object + test("should mask email and phone in object", () => { + const input = { + email: "abc@gmail.com", + phone: "9876543210" + }; + + const output = maskData(input); + + expect(output.email).not.toContain("abc@gmail.com"); + expect(output.phone.endsWith("3210")).toBe(true); + }); + + // Should recursively mask nested object + test("should recursively mask nested object", () => { + const input = { + user: { + email: "nested@gmail.com", + phone: "9876543210" + } + }; + + const output = maskData(input); + + expect(output.user.email).not.toContain("nested@gmail.com"); + expect(output.user.phone.endsWith("3210")).toBe(true); + }); + + /* ---------------- SAFE STRING TEST ---------------- */ + + // Should not modify safe string + test("should not change safe string", () => { + const input = "Service started successfully"; + const output = maskData(input); + expect(output).toBe(input); + }); + +}); + + +/* + MASKING TOGGLE TESTS +*/ + +describe("Masking Toggle Tests", () => { + + // Should return original value when masking disabled + test("should return original value when MASK_LOGS=false", () => { + + process.env.MASK_LOGS = "false"; + + jest.resetModules(); // reload module with updated env + const { maskData } = require('../logger/mask'); + + const input = "test@gmail.com"; + const output = maskData(input); + + expect(output).toBe("test@gmail.com"); + + process.env.MASK_LOGS = "true"; // restore for other tests + }); + +}); 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..0a36d3a --- /dev/null +++ b/src/logger/mask.js @@ -0,0 +1,148 @@ +// 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 = "********"; + +// 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 => { + const value = cloned[key]; + + if (key === "name" && 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 };