Add scraperLogger.js with credential redaction (#6)
Co-authored-by: Stephen Minakian <stephenminakian@gmail.com> Co-committed-by: Stephen Minakian <stephenminakian@gmail.com>
This commit is contained in:
164
services/scraperLogger.js
Normal file
164
services/scraperLogger.js
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Scraper-specific structured JSON logger
|
||||
* Produces one JSON object per line to stdout
|
||||
*/
|
||||
|
||||
const LEVELS = {
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a logger instance scoped to a job ID
|
||||
* @param {string} jobId - Job identifier for correlation
|
||||
* @returns {Object} Logger object with info, warn, error methods
|
||||
*/
|
||||
function createLogger(jobId) {
|
||||
/**
|
||||
* Internal log function
|
||||
* @param {string} level - Log level
|
||||
* @param {string} message - Log message
|
||||
* @param {Object} context - Additional context data
|
||||
*/
|
||||
const log = (level, message, context = {}) => {
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: truncateMessage(message),
|
||||
jobId,
|
||||
context: sanitizeContext(context)
|
||||
};
|
||||
|
||||
// Output as single-line JSON
|
||||
console.log(JSON.stringify(entry));
|
||||
};
|
||||
|
||||
return {
|
||||
info: (message, context) => log(LEVELS.info, message, context),
|
||||
warn: (message, context) => log(LEVELS.warn, message, context),
|
||||
error: (message, context) => log(LEVELS.error, message, context)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate message to prevent log bloat
|
||||
* @param {string} message - Message to truncate
|
||||
* @param {number} maxLength - Maximum length (default 1000)
|
||||
* @returns {string} Truncated message
|
||||
*/
|
||||
function truncateMessage(message, maxLength = 1000) {
|
||||
if (message === null || message === undefined) {
|
||||
return '';
|
||||
}
|
||||
const str = String(message);
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, maxLength) + '... [truncated]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process an object to handle Buffers, circular references, and sensitive data
|
||||
* @param {*} obj - Object to process
|
||||
* @param {WeakSet} seen - Set of seen objects for circular reference detection
|
||||
* @returns {*} Processed value
|
||||
*/
|
||||
function processValue(obj, seen = new WeakSet()) {
|
||||
// Handle null/undefined
|
||||
if (obj === null) {
|
||||
return null;
|
||||
}
|
||||
if (obj === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle Buffer BEFORE checking for object (Buffer is an object)
|
||||
if (Buffer.isBuffer(obj)) {
|
||||
return `[Buffer: ${obj.length} bytes]`;
|
||||
}
|
||||
|
||||
// Handle strings - check for MongoDB connection strings
|
||||
if (typeof obj === 'string') {
|
||||
if (/mongodb(\+srv)?:\/\//.test(obj)) {
|
||||
return redactConnectionString(obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle primitives
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle circular references
|
||||
if (seen.has(obj)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => processValue(item, seen));
|
||||
}
|
||||
|
||||
// Handle plain objects
|
||||
const result = {};
|
||||
const sensitiveKeys = ['password', 'secret', 'token', 'apikey', 'authorization'];
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
// Check for sensitive keys
|
||||
if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) {
|
||||
result[key] = '[REDACTED]';
|
||||
} else {
|
||||
result[key] = processValue(obj[key], seen);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize context object for safe logging
|
||||
* - Remove circular references
|
||||
* - Redact sensitive data
|
||||
* - Handle special types (Buffer, undefined)
|
||||
* @param {Object} context - Context object
|
||||
* @returns {Object} Sanitized context
|
||||
*/
|
||||
function sanitizeContext(context) {
|
||||
if (!context || typeof context !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return processValue(context);
|
||||
} catch (error) {
|
||||
// If sanitization fails, return empty context
|
||||
return { sanitizationError: 'Failed to sanitize context' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact credentials from MongoDB connection string
|
||||
* @param {string} uri - Connection string
|
||||
* @returns {string} Redacted string
|
||||
*/
|
||||
function redactConnectionString(uri) {
|
||||
try {
|
||||
// Match mongodb://user:pass@host or mongodb+srv://user:pass@host
|
||||
return uri.replace(
|
||||
/mongodb(\+srv)?:\/\/([^:]+):([^@]+)@/,
|
||||
'mongodb$1://[user]:[REDACTED]@'
|
||||
);
|
||||
} catch {
|
||||
return '[REDACTED CONNECTION STRING]';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLogger,
|
||||
LEVELS,
|
||||
// Export internal functions for testing
|
||||
redactConnectionString
|
||||
};
|
||||
Reference in New Issue
Block a user