diff --git a/src/index.ts b/src/index.ts index f3dca94..16c35d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,11 +23,15 @@ import { multiSigSubmissionService } from "./services/multiSigSubmissionService" import { apiKeyMiddleware } from "./middleware/apiKeyMiddleware"; import { rateLimitMiddleware } from "./middleware/rateLimitMiddleware"; import { validateEnv } from "./utils/envValidator"; +import { enableGlobalLogMasking } from "./utils/logMasker"; import { hourlyAverageService } from "./services/hourlyAverageService"; // Load environment variables dotenv.config(); +// Enable log masking to prevent sensitive data leaks +enableGlobalLogMasking(); + // [OPS] Implement "Environment Variable" Check on Start validateEnv(); diff --git a/src/utils/logMasker.ts b/src/utils/logMasker.ts new file mode 100644 index 0000000..792c7cb --- /dev/null +++ b/src/utils/logMasker.ts @@ -0,0 +1,179 @@ +/** + * Log Masking Utility + * Scrubs sensitive data (secrets, API keys, passwords, etc.) from log output + * to prevent leaking confidential information to stdout/stderr. + */ + +// Patterns for detecting sensitive values +const SENSITIVE_PATTERNS = [ + // Environment variable names that contain sensitive data + /\b(SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL|PRIVATE|API_KEY|APIKEY|AUTH|PK)\b/gi, + + // Stellar secret keys (start with 'S' and are 56 characters base32, typically A-Z and 2-7) + /\bS[A-Z2-7]{48,56}\b/g, + + // Ethereum-style private keys (64 hex chars or 66 with 0x prefix) + /\b(0x)?[a-fA-F0-9]{64}\b/g, + + // Common Bearer tokens + /Bearer\s+[A-Za-z0-9._-]+/gi, + + // Database connection strings with passwords + /(:\/\/[^:]+:)([^@]+)(@)/g, + + // AWS-style access keys (AKIA followed by 16 alphanumeric chars) + /AKIA[0-9A-Z]{16}/g, +]; + +/** + * Masks sensitive values in a string by replacing them with [REDACTED] + * @param input - The string to mask + * @returns The masked string with sensitive data replaced + */ +export function maskSensitiveData(input: string): string { + if (!input || typeof input !== "string") { + return input; + } + + let masked = input; + + // Apply each pattern + for (const pattern of SENSITIVE_PATTERNS) { + masked = masked.replace(pattern, (match) => { + // For database connection strings, preserve the connection type + if (match.includes("://")) { + return match.replace(/(:\/\/[^:]+:)([^@]+)(@)/, "$1[REDACTED]$3"); + } + // For Bearer tokens, preserve the scheme + if (match.toLowerCase().startsWith("bearer")) { + return "Bearer [REDACTED]"; + } + // For other matches, just redact + return "[REDACTED]"; + }); + } + + return masked; +} + +/** + * Masks sensitive data in an object (recursively) + * @param obj - The object to mask + * @returns A new object with sensitive values masked + */ +export function maskSensitiveObject( + obj: Record, +): Record { + if (!obj || typeof obj !== "object") { + return obj; + } + + const masked: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + // Check if the key name suggests sensitive data + if (/secret|password|token|key|credential|private|api|auth/i.test(key)) { + masked[key] = "[REDACTED]"; + } else if (typeof value === "string") { + masked[key] = maskSensitiveData(value); + } else if (Array.isArray(value)) { + masked[key] = value.map((item) => + typeof item === "string" ? maskSensitiveData(item) : item, + ); + } else if (typeof value === "object" && value !== null) { + masked[key] = maskSensitiveObject(value); + } else { + masked[key] = value; + } + } + + return masked; +} + +/** + * Creates a masked console object that automatically scrubs logs + * Replace console.log, console.error, etc. with these versions + */ +export const maskedConsole = { + log: (...args: any[]): void => { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + console.log(...maskedArgs); + }, + + error: (...args: any[]): void => { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + console.error(...maskedArgs); + }, + + warn: (...args: any[]): void => { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + console.warn(...maskedArgs); + }, + + info: (...args: any[]): void => { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + console.info(...maskedArgs); + }, + + debug: (...args: any[]): void => { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + console.debug(...maskedArgs); + }, +}; + +/** + * Intercepts all console methods and applies masking + * Call this once at application startup to enable global log masking + */ +export function enableGlobalLogMasking(): void { + const originalLog = console.log; + const originalError = console.error; + const originalWarn = console.warn; + const originalInfo = console.info; + const originalDebug = console.debug; + + console.log = function (...args: any[]): void { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + originalLog(...maskedArgs); + }; + + console.error = function (...args: any[]): void { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + originalError(...maskedArgs); + }; + + console.warn = function (...args: any[]): void { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + originalWarn(...maskedArgs); + }; + + console.info = function (...args: any[]): void { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + originalInfo(...maskedArgs); + }; + + console.debug = function (...args: any[]): void { + const maskedArgs = args.map((arg) => + typeof arg === "string" ? maskSensitiveData(arg) : arg, + ); + originalDebug(...maskedArgs); + }; +} diff --git a/test/logMasker.test.ts b/test/logMasker.test.ts new file mode 100644 index 0000000..2c1562a --- /dev/null +++ b/test/logMasker.test.ts @@ -0,0 +1,118 @@ +import { maskSensitiveData, maskSensitiveObject } from "../src/utils/logMasker"; + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(message); + } +} + +async function run(): Promise { + // Test 1: Mask Stellar secret key (48-56 characters starting with S, base32) + const stellarSecret = + "SBCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV"; + const maskedSecret = maskSensitiveData(stellarSecret); + assert( + maskedSecret.includes("[REDACTED]"), + "Stellar secret key should be masked", + ); + + // Test 2: Mask values with PASSWORD keyword + const passwordMsg = "User password is: myverysecurepassword123"; + const maskedPasswordMsg = maskSensitiveData(passwordMsg); + assert( + maskedPasswordMsg.includes("[REDACTED]"), + "PASSWORD keyword should trigger masking", + ); + + // Test 3: Mask database connection string with password + const dbUrl = "postgresql://username:mypassword@localhost:5432/mydb"; + const maskedDbUrl = maskSensitiveData(dbUrl); + assert( + maskedDbUrl.includes("[REDACTED]"), + "Database password should be masked", + ); + assert(maskedDbUrl.includes("username"), "Username should not be masked"); + assert( + !maskedDbUrl.includes("mypassword"), + "Password should not appear in output", + ); + + // Test 4: Mask Bearer token + const bearerToken = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; + const maskedBearer = maskSensitiveData(bearerToken); + assert(maskedBearer.includes("[REDACTED]"), "Bearer token should be masked"); + assert( + maskedBearer.startsWith("Bearer"), + "Bearer prefix should be preserved", + ); + + // Test 5: Mask object with sensitive keys + const sensitiveObj = { + username: "john", + password: "supersecret", + api_key: "sk_test_12345", + data: { + token: "abcdef123456", + value: 100, + }, + }; + const maskedObj = maskSensitiveObject(sensitiveObj); + assert( + maskedObj.password === "[REDACTED]", + "password field should be redacted", + ); + assert( + maskedObj.api_key === "[REDACTED]", + "api_key field should be redacted", + ); + assert( + maskedObj.data.token === "[REDACTED]", + "nested token field should be redacted", + ); + assert( + maskedObj.username === "john", + "non-sensitive fields should not be masked", + ); + assert(maskedObj.data.value === 100, "numeric values should not be affected"); + + // Test 6: Mask AWS access key + const awsKey = "AKIAIOSFODNN7EXAMPLE"; + const maskedAwsKey = maskSensitiveData(awsKey); + assert( + maskedAwsKey.includes("[REDACTED]"), + "AWS access key should be masked", + ); + + // Test 7: Mask SECRET keyword + const secretMsg = "Loaded secret key from vault"; + const maskedSecretMsg = maskSensitiveData(secretMsg); + assert( + maskedSecretMsg.includes("[REDACTED]"), + "SECRET keyword should trigger masking", + ); + + // Test 8: Non-sensitive strings should pass through unchanged + const normalMsg = "Processing request for user john_doe"; + const maskedNormal = maskSensitiveData(normalMsg); + assert( + maskedNormal === normalMsg, + "Non-sensitive strings should not be masked", + ); + + // Test 9: Empty and null inputs + assert( + maskSensitiveData("") === "", + "Empty string should return empty string", + ); + assert( + maskSensitiveData(null as any) === null, + "Null input should return null", + ); + + console.log("✅ All log masking tests passed"); +} + +run().catch((error) => { + console.error("❌ Test failed:", error.message); + process.exit(1); +});